diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/ProjectSection/index.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/ProjectSection/index.tsx
index eea74806fe..5466d528ea 100644
--- a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/ProjectSection/index.tsx
+++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/_components/Sidebar/ProjectSection/index.tsx
@@ -3,6 +3,7 @@
import { formatCount } from '$/lib/formatCount'
import { ROUTES } from '$/services/routes'
import { useActiveRunsCount } from '$/stores/runs/activeRuns'
+import useFeature from '$/stores/useFeature'
import { Commit } from '@latitude-data/core/schema/models/types/Commit'
import { Project } from '@latitude-data/core/schema/models/types/Project'
@@ -84,6 +85,7 @@ export default function ProjectSection({
limitedView?: boolean
}) {
const disableRunsNotifications = !!limitedView
+ const issuesFeature = useFeature('issues')
const { data: active } = useActiveRunsCount({
project: project,
realtime: !disableRunsNotifications,
@@ -113,6 +115,13 @@ export default function ProjectSection({
: `There are ${count} runs in progress`,
},
},
+ issuesFeature.isEnabled && {
+ label: 'Issues',
+ iconName: 'shieldAlert',
+ route: ROUTES.projects
+ .detail({ id: project.id })
+ .commits.detail({ uuid: commit.uuid }).issues.root,
+ },
{
label: 'Analytics',
route: ROUTES.projects
@@ -128,7 +137,13 @@ export default function ProjectSection({
iconName: 'history',
},
].filter(Boolean) as ProjectRoute[],
- [project, commit, active, disableRunsNotifications],
+ [
+ project,
+ commit,
+ issuesFeature.isEnabled,
+ active,
+ disableRunsNotifications,
+ ],
)
return (
diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/(withTabs)/logs/page.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/(withTabs)/logs/page.tsx
index ae16290450..868af19123 100644
--- a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/(withTabs)/logs/page.tsx
+++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/documents/[documentUuid]/(withTabs)/logs/page.tsx
@@ -103,7 +103,7 @@ export default async function DocumentPage({
await getDocumentLogsApproximatedCountCached(documentUuid)
if (approximatedCount > LIMITED_VIEW_THRESHOLD) {
return DocumentLogsLimitedPage({
- workspace: workspace,
+ workspace,
projectId: projectId,
commit: commit,
document: document,
diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesDashboard/index.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesDashboard/index.tsx
new file mode 100644
index 0000000000..d97a08b040
--- /dev/null
+++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesDashboard/index.tsx
@@ -0,0 +1,93 @@
+'use client'
+
+import { useMemo, useRef } from 'react'
+import { useSearchParams } from 'next/navigation'
+import { useIssues } from '$/stores/issues'
+import {
+ SafeIssuesParams,
+ convertIssuesParamsToQueryParams,
+} from '@latitude-data/constants/issues'
+import { useCurrentCommit } from '$/app/providers/CommitProvider'
+import { useCurrentProject } from '$/app/providers/ProjectProvider'
+import { useIssuesParameters } from '$/stores/issues/useIssuesParameters'
+import { useOnce } from '$/hooks/useMount'
+import { TableWithHeader } from '@latitude-data/web-ui/molecules/ListingHeader'
+import { ROUTES } from '$/services/routes'
+import { IssuesServerResponse } from '../../page'
+import { IssuesTable } from '../IssuesTable'
+import { IssuesFilters } from '../IssuesFilters'
+import { SearchIssuesInput } from '../SearchIssuesInput'
+
+export function IssuesDashboard({
+ serverResponse,
+ params,
+}: {
+ serverResponse: IssuesServerResponse
+ params: SafeIssuesParams
+}) {
+ const { init, urlParameters, onSuccessIssuesFetch } = useIssuesParameters(
+ (state) => ({
+ init: state.init,
+ urlParameters: state.urlParameters,
+ onSuccessIssuesFetch: state.onSuccessIssuesFetch,
+ }),
+ )
+ const queryParams = useSearchParams()
+ const { project } = useCurrentProject()
+ const { commit } = useCurrentCommit()
+ const page = Number(queryParams.get('page') ?? '1')
+ const initialPage = useRef(page)
+ const currentRoute = ROUTES.projects
+ .detail({ id: project.id })
+ .commits.detail({ uuid: commit.uuid }).issues.root
+ const searchParams = useMemo(() => {
+ if (!urlParameters) return convertIssuesParamsToQueryParams(params)
+ return urlParameters
+ }, [urlParameters, params])
+ const { data: issues, isLoading } = useIssues(
+ {
+ projectId: project.id,
+ commitUuid: commit.uuid,
+ searchParams,
+ initialPage: initialPage.current,
+ onSuccess: onSuccessIssuesFetch,
+ },
+ {
+ fallbackData: serverResponse,
+ },
+ )
+
+ useOnce(() => {
+ init({
+ params: {
+ ...params,
+ totalCount: serverResponse.totalCount,
+ },
+ onStateChange: (queryParams) => {
+ // NOTE: Next.js do RSC navigation, so we need to use the History API to avoid a full page reload
+ window.history.replaceState(
+ null,
+ '',
+ `${currentRoute}?${new URLSearchParams(queryParams).toString()}`,
+ )
+ },
+ })
+ })
+
+ return (
+
+
}
+ actions={
}
+ table={
+
+ }
+ />
+
+ )
+}
diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesFilters/index.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesFilters/index.tsx
new file mode 100644
index 0000000000..02a424550d
--- /dev/null
+++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesFilters/index.tsx
@@ -0,0 +1,81 @@
+import { useCallback } from 'react'
+import { useIssuesParameters } from '$/stores/issues/useIssuesParameters'
+import { DatePickerRange } from '@latitude-data/web-ui/atoms/DatePicker'
+import { RadioToggleInput } from '@latitude-data/web-ui/molecules/RadioToggleInput'
+import { Select } from '@latitude-data/web-ui/atoms/Select'
+import {
+ SafeIssuesParams,
+ ISSUE_STATUS,
+ IssueStatus,
+} from '@latitude-data/constants/issues'
+import { useDocuments } from './useDocuments'
+import { useSeenAtDatePicker } from './useSeenAtDatePicker'
+
+const STATUS_OPTIONS = [
+ { label: 'Active', value: ISSUE_STATUS.active },
+ { label: 'Regressed', value: ISSUE_STATUS.regressed },
+ { label: 'Archived', value: ISSUE_STATUS.archived },
+]
+export function IssuesFilters({
+ serverParams,
+}: {
+ serverParams: SafeIssuesParams
+}) {
+ const { filters, setFilters } = useIssuesParameters((state) => ({
+ filters: state.filters,
+ setFilters: state.setFilters,
+ }))
+ const { dateWindow, onDateWindowChange } = useSeenAtDatePicker({
+ serverParams,
+ })
+ const { documentOptions, isLoading: isLoadingDocuments } = useDocuments()
+
+ const onStatusChange = useCallback(
+ (status: IssueStatus) => {
+ setFilters({ status })
+ },
+ [setFilters],
+ )
+
+ const onDocumentChange = useCallback(
+ (documentUuid?: string | null) => {
+ setFilters({ documentUuid })
+ },
+ [setFilters],
+ )
+
+ return (
+ <>
+
+
+
+
+ >
+ )
+}
diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesFilters/useDocuments.ts b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesFilters/useDocuments.ts
new file mode 100644
index 0000000000..abadcfd4cb
--- /dev/null
+++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesFilters/useDocuments.ts
@@ -0,0 +1,47 @@
+import { useMemo } from 'react'
+import { useCurrentCommit } from '$/app/providers/CommitProvider'
+import { useCurrentProject } from '$/app/providers/ProjectProvider'
+import useDocumentVersions from '$/stores/documentVersions'
+
+function formatDocumentPath(path: string): string {
+ // Remove .promptl extension if present
+ const cleanPath = path.replace(/\.promptl$/, '')
+ const segments = cleanPath.split('/')
+
+ if (segments.length === 1) return segments[0]
+
+ const filename = segments[segments.length - 1]
+ const firstFolder = segments[0]
+
+ if (segments.length === 2) {
+ return `${firstFolder}/${filename}`
+ }
+
+ return `${firstFolder}/.../${filename}`
+}
+
+export function useDocuments() {
+ const { project } = useCurrentProject()
+ const { commit } = useCurrentCommit()
+ const { data: documents, isLoading } = useDocumentVersions({
+ projectId: project?.id,
+ commitUuid: commit?.uuid,
+ })
+
+ const documentOptions = useMemo(
+ () =>
+ documents?.map((doc) => ({
+ value: doc.documentUuid,
+ label: formatDocumentPath(doc.path),
+ })),
+ [documents],
+ )
+
+ return useMemo(
+ () => ({
+ documentOptions,
+ isLoading,
+ }),
+ [documentOptions, isLoading],
+ )
+}
diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesFilters/useSeenAtDatePicker.ts b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesFilters/useSeenAtDatePicker.ts
new file mode 100644
index 0000000000..ecd04ea332
--- /dev/null
+++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesFilters/useSeenAtDatePicker.ts
@@ -0,0 +1,66 @@
+import { useMemo, useCallback } from 'react'
+import { useIssuesParameters } from '$/stores/issues/useIssuesParameters'
+import { SafeIssuesParams } from '@latitude-data/constants/issues'
+import { DateRange } from '@latitude-data/web-ui/atoms/DatePicker'
+
+export function useSeenAtDatePicker({
+ serverParams,
+}: {
+ serverParams: SafeIssuesParams
+}) {
+ const { filters, setFilters } = useIssuesParameters((state) => ({
+ filters: state.filters,
+ setFilters: state.setFilters,
+ }))
+ const firstSeen = filters.firstSeen ?? serverParams.filters.firstSeen
+ const lastSeen = filters.lastSeen ?? serverParams.filters.lastSeen
+ const firstSeenDate = firstSeen instanceof Date ? firstSeen : undefined
+ const lastSeenDate = lastSeen instanceof Date ? lastSeen : undefined
+
+ const dateWindow: DateRange | undefined = useMemo(
+ () =>
+ firstSeenDate && lastSeenDate
+ ? { from: firstSeenDate, to: lastSeenDate }
+ : firstSeenDate
+ ? { from: firstSeenDate, to: firstSeenDate }
+ : lastSeenDate
+ ? { from: lastSeenDate, to: lastSeenDate }
+ : undefined,
+ [firstSeenDate, lastSeenDate],
+ )
+
+ const onDateWindowChange = useCallback(
+ (range: DateRange | undefined) => {
+ // Convert DateRange back to single Date values
+ // If only one date is selected (from without to), treat it as lastSeen filter only
+ // This gives "show issues last seen until this date" behavior
+ if (range?.from && !range?.to) {
+ setFilters({
+ firstSeen: undefined,
+ lastSeen: range.from,
+ })
+ } else if (range?.from && range?.to) {
+ // Full range: firstSeen represents the start, lastSeen represents the end
+ setFilters({
+ firstSeen: range.from,
+ lastSeen: range.to,
+ })
+ } else {
+ // Clear filters when no date is selected
+ setFilters({
+ firstSeen: undefined,
+ lastSeen: undefined,
+ })
+ }
+ },
+ [setFilters],
+ )
+
+ return useMemo(
+ () => ({
+ dateWindow,
+ onDateWindowChange,
+ }),
+ [dateWindow, onDateWindowChange],
+ )
+}
diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesTable/index.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesTable/index.tsx
new file mode 100644
index 0000000000..eb06895f0c
--- /dev/null
+++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesTable/index.tsx
@@ -0,0 +1,145 @@
+import { LinkableTablePaginationFooter } from '$/components/TablePaginationFooter'
+import { buildPagination } from '@latitude-data/core/lib/pagination/buildPagination'
+import {
+ Table,
+ TableHeader,
+ TableRow,
+ TableHead,
+ TableBody,
+ TableCell,
+} from '@latitude-data/web-ui/atoms/Table'
+import { TableBlankSlate } from '@latitude-data/web-ui/molecules/TableBlankSlate'
+import { Skeleton } from '@latitude-data/web-ui/atoms/Skeleton'
+import { useIssuesParameters } from '$/stores/issues/useIssuesParameters'
+import { SerializedIssue } from '$/stores/issues'
+import { IssuesTitle } from '../IssuesTitle'
+import { LastSeenCell } from '../LastSeenCell'
+import { Text } from '@latitude-data/web-ui/atoms/Text'
+import { SafeIssuesParams } from '@latitude-data/constants/issues'
+import { useMemo } from 'react'
+
+const FAKE_ROWS = Array.from({ length: 20 })
+function IssuesTableLoader({ showStatus }: { showStatus: boolean }) {
+ const headerCells = useMemo(
+ () => Array.from({ length: showStatus ? 4 : 3 }),
+ [showStatus],
+ )
+ return (
+ <>
+ {FAKE_ROWS.map((_, index) => (
+
+ {headerCells.map((_, cellIndex) => (
+
+
+
+ ))}
+
+ ))}
+ >
+ )
+}
+
+function StatusCell({ issue }: { issue: SerializedIssue }) {
+ const status = issue.isResolved
+ ? 'Resolved'
+ : issue.isIgnored
+ ? 'Ignored'
+ : 'Active'
+ return {status}
+}
+
+export function IssuesTable({
+ serverParams,
+ isLoading,
+ issues,
+ currentRoute,
+}: {
+ serverParams: SafeIssuesParams
+ isLoading: boolean
+ issues: SerializedIssue[]
+ currentRoute: string
+}) {
+ const {
+ page,
+ hasPrevPage,
+ prevPage,
+ hasNextPage,
+ nextPage,
+ totalCount,
+ limit,
+ filters,
+ } = useIssuesParameters((state) => ({
+ init: state.init,
+ page: state.page,
+ urlParameters: state.urlParameters,
+ prevPage: state.prevPage,
+ hasPrevPage: state.hasPrevPage,
+ nextPage: state.nextPage,
+ hasNextPage: state.hasNextPage,
+ totalCount: state.totalCount,
+ limit: state.limit,
+ filters: state.filters,
+ }))
+ const noData = !isLoading && !issues.length
+ const status = filters.status ?? serverParams.filters.status
+ const showStatus = status !== 'active'
+
+ if (noData) return
+
+ return (
+ `${count} issues`}
+ onPrev={prevPage}
+ prevPageDisabled={!hasPrevPage}
+ nextPageDisabled={!hasNextPage}
+ onNext={nextPage}
+ pagination={buildPagination({
+ count: totalCount,
+ baseUrl: currentRoute,
+ page: Number(page),
+ pageSize: limit,
+ })}
+ />
+ }
+ >
+
+
+
+ Seen at
+ Events
+ {showStatus ? Status : null}
+
+
+
+ {isLoading ? (
+
+ ) : (
+ issues.map((issue) => (
+
+
+
+
+
+
+
+ {issue.totalCount}
+ {showStatus ? (
+
+
+
+ ) : null}
+
+ ))
+ )}
+
+
+ )
+}
diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesTitle/index.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesTitle/index.tsx
new file mode 100644
index 0000000000..d7acae05e5
--- /dev/null
+++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/IssuesTitle/index.tsx
@@ -0,0 +1,37 @@
+import { SerializedIssue } from '$/stores/issues'
+import { Tooltip } from '@latitude-data/web-ui/atoms/Tooltip'
+import { Badge } from '@latitude-data/web-ui/atoms/Badge'
+import { Text } from '@latitude-data/web-ui/atoms/Text'
+import {
+ NEW_ISSUES_DAYS,
+ RECENT_ISSUES_DAYS,
+} from '@latitude-data/constants/issues'
+
+function StatusBadge({ issue }: { issue: SerializedIssue }) {
+ const isNew = issue.isNew
+ const isEscalating = issue.isEscalating
+ if (!isNew && !isEscalating) return null
+
+ if (isNew) {
+ return (
+ New}>
+ {`Has appeared in the last ${NEW_ISSUES_DAYS} days`}
+
+ )
+ }
+
+ return (
+ Escalating}>
+ {`${issue.escalatingCount} events in the last ${RECENT_ISSUES_DAYS} days.`}
+
+ )
+}
+
+export function IssuesTitle({ issue }: { issue: SerializedIssue }) {
+ return (
+
+
+ {issue.title}
+
+ )
+}
diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/LastSeenCell/index.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/LastSeenCell/index.tsx
new file mode 100644
index 0000000000..45fe61cac1
--- /dev/null
+++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/LastSeenCell/index.tsx
@@ -0,0 +1,66 @@
+import { useMemo } from 'react'
+import { SerializedIssue } from '$/stores/issues'
+import { Text } from '@latitude-data/web-ui/atoms/Text'
+import {
+ differenceInMinutes,
+ differenceInHours,
+ differenceInDays,
+ differenceInWeeks,
+ differenceInMonths,
+ differenceInYears,
+ format,
+} from 'date-fns'
+import { Tooltip } from '@latitude-data/web-ui/atoms/Tooltip'
+
+function formatTimeDistance(date: Date, baseDate: Date = new Date()): string {
+ const minutes = differenceInMinutes(baseDate, date)
+ const hours = differenceInHours(baseDate, date)
+ const days = differenceInDays(baseDate, date)
+ const weeks = differenceInWeeks(baseDate, date)
+ const months = differenceInMonths(baseDate, date)
+ const years = differenceInYears(baseDate, date)
+
+ if (minutes < 1) return 'less than a minute'
+ if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'}`
+ if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'}`
+ if (days < 7) return `${days} day${days === 1 ? '' : 's'}`
+ if (days < 30) return `${weeks} week${weeks === 1 ? '' : 's'}`
+ if (months < 12) return `${months} month${months === 1 ? '' : 's'}`
+ return `${years} year${years === 1 ? '' : 's'}`
+}
+
+function getRelativeTimeText(createdAt: Date, lastSeenDate: Date | null) {
+ const dateToUse = lastSeenDate || createdAt
+ const now = new Date()
+
+ const ageText = formatTimeDistance(createdAt, now)
+ const lastSeenText = formatTimeDistance(dateToUse, now)
+
+ return `${lastSeenText} ago / ${ageText} old`
+}
+
+export function LastSeenCell({ issue }: { issue: SerializedIssue }) {
+ const text = useMemo(
+ () => getRelativeTimeText(issue.createdAt, issue.lastSeenDate),
+ [issue.createdAt, issue.lastSeenDate],
+ )
+
+ const tooltipContent = useMemo(() => {
+ const firstSeenFormatted = format(issue.createdAt, 'PPpp')
+ const lastSeenFormatted = issue.lastSeenDate
+ ? format(issue.lastSeenDate, 'PPpp')
+ : 'Never'
+ return (
+
+
Last seen: {lastSeenFormatted}
+
First seen: {firstSeenFormatted}
+
+ )
+ }, [issue.createdAt, issue.lastSeenDate])
+
+ return (
+ {text}}>
+ {tooltipContent}
+
+ )
+}
diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/SearchIssuesInput/index.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/SearchIssuesInput/index.tsx
new file mode 100644
index 0000000000..8e2ebc8224
--- /dev/null
+++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/_components/SearchIssuesInput/index.tsx
@@ -0,0 +1,54 @@
+import { ChangeEvent, useCallback, useState } from 'react'
+import { useIssuesParameters } from '$/stores/issues/useIssuesParameters'
+import { SafeIssuesParams } from '@latitude-data/constants/issues'
+import { Icon } from '@latitude-data/web-ui/atoms/Icons'
+import { cn } from '@latitude-data/web-ui/utils'
+import { useDebouncedCallback } from 'use-debounce'
+
+export function SearchIssuesInput({
+ serverParams,
+}: {
+ serverParams: SafeIssuesParams
+}) {
+ const { filters, setFilters } = useIssuesParameters((state) => ({
+ filters: state.filters,
+ setFilters: state.setFilters,
+ }))
+ const [query, setQuery] = useState(
+ filters.query ?? serverParams.filters?.query ?? '',
+ )
+ const onChangeQuery = useDebouncedCallback((value: string) => {
+ setFilters({
+ ...filters,
+ query: value || undefined,
+ })
+ }, 300)
+ const onChange = useCallback(
+ (e: ChangeEvent) => {
+ setQuery(e.target.value)
+ onChangeQuery(e.target.value)
+ },
+ [onChangeQuery],
+ )
+ return (
+
+
+
+
+ )
+}
diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/layout.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/layout.tsx
new file mode 100644
index 0000000000..e1cfd9a1a2
--- /dev/null
+++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/layout.tsx
@@ -0,0 +1,25 @@
+import { ReactNode } from 'react'
+import { Metadata } from 'next'
+import buildMetatags from '$/app/_lib/buildMetatags'
+import ProjectLayout from '../_components/ProjectLayout'
+
+export async function generateMetadata(): Promise {
+ return buildMetatags({
+ title: 'Issues',
+ })
+}
+
+export default async function IssuesLayout({
+ children,
+ params,
+}: {
+ children: ReactNode
+ params: Promise<{ projectId: string; commitUuid: string }>
+}) {
+ const { projectId, commitUuid } = await params
+ return (
+
+ {children}
+
+ )
+}
diff --git a/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/page.tsx b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/page.tsx
new file mode 100644
index 0000000000..a05fc7a7d6
--- /dev/null
+++ b/apps/web/src/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/page.tsx
@@ -0,0 +1,68 @@
+import { IssuesDashboard } from './_components/IssuesDashboard'
+import {
+ findCommitCached,
+ findProjectCached,
+} from '$/app/(private)/_data-access'
+import { getCurrentUserOrRedirect } from '$/services/auth/getCurrentUser'
+import { IssuesRepository } from '@latitude-data/core/repositories'
+import { OkType } from '@latitude-data/core/lib/Result'
+import {
+ buildIssuesCacheKey,
+ convertIssuesParamsToQueryParams,
+ parseIssuesQueryParams,
+ QueryParams,
+} from '@latitude-data/constants/issues'
+import { SWRProvider } from '$/components/Providers/SWRProvider'
+
+export type IssuesServerResponse = Awaited<
+ OkType
+>
+
+export default async function IssuesPageRoute({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ projectId: string; commitUuid: string }>
+ searchParams: Promise
+}) {
+ const queryParams = await searchParams
+ const { projectId, commitUuid } = await params
+ const session = await getCurrentUserOrRedirect()
+ const project = await findProjectCached({
+ workspaceId: session.workspace.id,
+ projectId: Number(projectId),
+ })
+ const commit = await findCommitCached({
+ uuid: commitUuid,
+ projectId: Number(projectId),
+ })
+ const parsedParams = parseIssuesQueryParams({
+ params: queryParams,
+ defaultFilters: { documentUuid: commit.mainDocumentUuid ?? undefined },
+ })
+ const issuesRepo = new IssuesRepository(session.workspace.id)
+ const args = {
+ project,
+ commit,
+ filters: parsedParams.filters,
+ sorting: parsedParams.sorting,
+ page: parsedParams.page,
+ }
+ const serverResponse = await issuesRepo
+ .fetchIssuesFiltered({
+ ...args,
+ limit: parsedParams.limit,
+ })
+ .then((r) => r.unwrap())
+
+ const key = buildIssuesCacheKey({
+ projectId: project.id,
+ commitUuid: commit.uuid,
+ searchParams: convertIssuesParamsToQueryParams(parsedParams),
+ })
+ return (
+
+
+
+ )
+}
diff --git a/apps/web/src/app/_lib/buildMetatags.ts b/apps/web/src/app/_lib/buildMetatags.ts
index a9c5ef494a..31febd803e 100644
--- a/apps/web/src/app/_lib/buildMetatags.ts
+++ b/apps/web/src/app/_lib/buildMetatags.ts
@@ -6,7 +6,7 @@ const DEFAULT_DESCRIPTION =
// This function is necessary to define default metadata correctly, because
// Nextjs metadata merging would overwrite the nested objects totally.
-export default function buildMetatags({
+export default async function buildMetatags({
title,
description,
locationDescription,
@@ -16,7 +16,7 @@ export default function buildMetatags({
description?: string
locationDescription?: string
parent?: ResolvedMetadata
-}): Metadata {
+}): Promise {
let parentTitle = parent?.title?.absolute || ''
let metaTitle =
title && parentTitle
diff --git a/apps/web/src/app/api/projects/[projectId]/commits/[commitUuid]/issues/route.ts b/apps/web/src/app/api/projects/[projectId]/commits/[commitUuid]/issues/route.ts
new file mode 100644
index 0000000000..94e8ca5f55
--- /dev/null
+++ b/apps/web/src/app/api/projects/[projectId]/commits/[commitUuid]/issues/route.ts
@@ -0,0 +1,63 @@
+import { z } from 'zod'
+import { authHandler } from '$/middlewares/authHandler'
+import { errorHandler } from '$/middlewares/errorHandler'
+import {
+ ProjectsRepository,
+ IssuesRepository,
+} from '@latitude-data/core/repositories'
+import { CommitsRepository } from '@latitude-data/core/repositories'
+import { Workspace } from '@latitude-data/core/schema/models/types/Workspace'
+import { NextRequest, NextResponse } from 'next/server'
+import { parseIssuesQueryParams } from '@latitude-data/constants/issues'
+
+const paramsSchema = z.object({
+ projectId: z.coerce.number(),
+ commitUuid: z.string(),
+})
+
+export const GET = errorHandler(
+ authHandler(
+ async (
+ request: NextRequest,
+ {
+ params,
+ workspace,
+ }: {
+ params: {
+ projectId: string
+ commitUuid: string
+ }
+ workspace: Workspace
+ },
+ ) => {
+ const { projectId, commitUuid } = paramsSchema.parse({
+ projectId: params.projectId,
+ commitUuid: params.commitUuid,
+ })
+ const query = request.nextUrl.searchParams
+ const parsed = parseIssuesQueryParams({ params: query })
+ const projectsRepo = new ProjectsRepository(workspace.id)
+ const project = await projectsRepo.find(projectId).then((r) => r.unwrap())
+ const commitsRepo = new CommitsRepository(workspace.id)
+ const commit = await commitsRepo
+ .getCommitByUuid({
+ projectId,
+ uuid: commitUuid,
+ })
+ .then((r) => r.unwrap())
+ const issuesRepo = new IssuesRepository(workspace.id)
+ const result = await issuesRepo
+ .fetchIssuesFiltered({
+ project,
+ commit,
+ filters: parsed.filters,
+ sorting: parsed.sorting,
+ page: parsed.page,
+ limit: parsed.limit,
+ })
+ .then((r) => r.unwrap())
+
+ return NextResponse.json(result, { status: 200 })
+ },
+ ),
+)
diff --git a/apps/web/src/components/TablePaginationFooter/GoToPageInput/index.tsx b/apps/web/src/components/TablePaginationFooter/GoToPageInput/index.tsx
index c19b5b07de..03cd6cb12d 100644
--- a/apps/web/src/components/TablePaginationFooter/GoToPageInput/index.tsx
+++ b/apps/web/src/components/TablePaginationFooter/GoToPageInput/index.tsx
@@ -1,5 +1,3 @@
-'use client'
-
import { FormEvent, useCallback } from 'react'
import { buildPaginatedUrl } from '@latitude-data/core/lib/pagination/buildPaginatedUrl'
@@ -52,6 +50,7 @@ export function GoToPageInput({
hideNativeAppearance
type='number'
name='page'
+ size='small'
min={1}
max={totalPages}
defaultValue={page}
diff --git a/apps/web/src/components/TablePaginationFooter/index.tsx b/apps/web/src/components/TablePaginationFooter/index.tsx
index d7a8455e79..e65bbd6360 100644
--- a/apps/web/src/components/TablePaginationFooter/index.tsx
+++ b/apps/web/src/components/TablePaginationFooter/index.tsx
@@ -1,3 +1,4 @@
+import { useMemo } from 'react'
import { IPagination } from '@latitude-data/core/lib/pagination/buildPagination'
import { Button } from '@latitude-data/web-ui/atoms/Button'
import { Skeleton } from '@latitude-data/web-ui/atoms/Skeleton'
@@ -9,7 +10,12 @@ type Props = {
pagination?: IPagination
countLabel?: (count: number) => string
isLoading?: boolean
+ onNext?: () => void
+ nextPageDisabled?: boolean
+ onPrev?: () => void
+ prevPageDisabled?: boolean
}
+
function CountLabel({
count,
isLoading = false,
@@ -32,29 +38,51 @@ function CountLabel({
function NavLink({
url,
direction,
+ onClick,
+ disabled = false,
}: {
url?: string
direction: 'prev' | 'next'
+ disabled?: boolean
+ onClick?: () => void
}) {
- const button = (
-
+ const buttonProps = useMemo(
+ () => ({
+ size: 'default' as const,
+ variant: 'ghost' as const,
+ iconProps: {
+ name:
+ direction === 'prev'
+ ? ('chevronLeft' as const)
+ : ('chevronRight' as const),
+ },
+ }),
+ [direction],
)
- if (!url) return button
- return {button}
+ if (onClick) {
+ return
+ }
+
+ if (!url || disabled) {
+ return
+ }
+
+ return (
+
+
+
+ )
}
export function LinkableTablePaginationFooter({
pagination,
countLabel,
isLoading = false,
+ prevPageDisabled = false,
+ onPrev,
+ nextPageDisabled = false,
+ onNext,
}: Props) {
if (!isLoading && !pagination) return null
@@ -70,7 +98,12 @@ export function LinkableTablePaginationFooter({
) : pagination ? (
-
+
Page
@@ -88,7 +121,12 @@ export function LinkableTablePaginationFooter({
) : null}
-
+
) : null}
diff --git a/apps/web/src/services/routes.ts b/apps/web/src/services/routes.ts
index e181e38e78..c89417fc7b 100644
--- a/apps/web/src/services/routes.ts
+++ b/apps/web/src/services/routes.ts
@@ -258,6 +258,9 @@ export const ROUTES = {
root: `${root}/runs/${uuid}`,
}),
},
+ issues: {
+ root: `${root}/issues`,
+ },
}
},
},
diff --git a/apps/web/src/services/routes/api.ts b/apps/web/src/services/routes/api.ts
index f80ad46bfb..28cecdd18f 100644
--- a/apps/web/src/services/routes/api.ts
+++ b/apps/web/src/services/routes/api.ts
@@ -133,6 +133,9 @@ export const API_ROUTES = {
}
},
},
+ issues: {
+ root: `${projectRoot}/commits/${commitUuid}/issues`,
+ },
documents: {
root: `${projectRoot}/commits/${commitUuid}/documents`,
detail: (documentUuid: string) => {
diff --git a/apps/web/src/stores/issues/index.ts b/apps/web/src/stores/issues/index.ts
new file mode 100644
index 0000000000..f1ccaa409d
--- /dev/null
+++ b/apps/web/src/stores/issues/index.ts
@@ -0,0 +1,118 @@
+import { useEffect, useMemo, useRef } from 'react'
+import useSWR, { SWRConfiguration } from 'swr'
+import { ROUTES } from '$/services/routes'
+import { IssuesServerResponse } from '$/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/page'
+import {
+ DEFAULTS_ISSUE_PARAMS,
+ IssuesFiltersQueryParamsInput,
+} from '@latitude-data/constants/issues'
+import useFetcher from '$/hooks/useFetcher'
+import { buildIssuesCacheKey } from '@latitude-data/constants/issues'
+
+const EMPTY_ISSUES_RESPONSE = {
+ issues: [],
+ page: 1,
+ limit: DEFAULTS_ISSUE_PARAMS.limit,
+ totalCount: 0,
+} satisfies IssuesServerResponse
+
+type Issue = IssuesServerResponse['issues'][number]
+export type SerializedIssue = Omit & {
+ lastSeenDate: Date
+ createdAt: Date
+}
+
+type IssuesResponseSerialized = Omit & {
+ issues: SerializedIssue[]
+}
+
+function serializer(response: IssuesServerResponse): IssuesResponseSerialized {
+ if (!response) return EMPTY_ISSUES_RESPONSE
+
+ return {
+ ...response,
+ issues: response.issues.map((issue) => ({
+ ...issue,
+ lastSeenDate: new Date(issue.lastSeenDate),
+ createdAt: new Date(issue.createdAt),
+ })),
+ }
+}
+
+export function useIssues(
+ {
+ projectId,
+ commitUuid,
+ initialPage,
+ searchParams,
+ onSuccess,
+ }: {
+ projectId: number
+ commitUuid: string
+ initialPage: number
+ searchParams: Partial
+ onSuccess: (data: IssuesServerResponse | void) => void
+ },
+ swrConfig?: SWRConfiguration,
+) {
+ const isSSR = useRef(!!swrConfig?.fallbackData)
+ const route = ROUTES.api.projects.detail(projectId).commits.detail(commitUuid)
+ .issues.root
+
+ const key = useMemo(
+ () =>
+ buildIssuesCacheKey({
+ projectId,
+ commitUuid,
+ searchParams,
+ }),
+ [projectId, commitUuid, searchParams],
+ )
+ const fetcher = useFetcher(
+ route,
+ {
+ searchParams,
+ onSuccess,
+ serializer,
+ },
+ )
+
+ const currentPage = Number(searchParams.page ?? initialPage)
+ const swrOptions = useMemo(() => {
+ return currentPage === initialPage
+ ? {
+ fallbackData: swrConfig?.fallbackData
+ ? serializer(swrConfig.fallbackData)
+ : undefined,
+ }
+ : {}
+ }, [swrConfig, currentPage, initialPage])
+
+ const hasFallbackData =
+ currentPage === initialPage && swrConfig?.fallbackData !== undefined
+
+ const { data = EMPTY_ISSUES_RESPONSE, isLoading } =
+ useSWR(key, fetcher, {
+ ...swrOptions,
+ keepPreviousData: true,
+ revalidateOnMount: !hasFallbackData, // Only revalidate on mount if we don't have server data
+ revalidateOnFocus: false,
+ // NOTE: We don't have caching because filters/sorting can change often
+ // Set this to false and do a manual revalidation when needed
+ revalidateIfStale: true,
+ revalidateOnReconnect: !isSSR.current,
+ })
+
+ useEffect(() => {
+ // This should never run more than once, only on mount
+ isSSR.current = false
+ }, [])
+
+ return useMemo(
+ () => ({
+ data: data.issues ?? [],
+ isLoading,
+ }),
+ [data, isLoading],
+ )
+}
diff --git a/apps/web/src/stores/issues/useIssuesParameters.ts b/apps/web/src/stores/issues/useIssuesParameters.ts
new file mode 100644
index 0000000000..f9226d2b89
--- /dev/null
+++ b/apps/web/src/stores/issues/useIssuesParameters.ts
@@ -0,0 +1,204 @@
+import { create } from 'zustand'
+import { IssuesServerResponse } from '$/app/(private)/projects/[projectId]/versions/[commitUuid]/issues/page'
+import {
+ DEFAULTS_ISSUE_PARAMS,
+ IssuesFiltersQueryParamsInput,
+ SafeIssuesParams,
+} from '@latitude-data/constants/issues'
+import { convertIssuesParamsToQueryParams } from '@latitude-data/constants/issues'
+
+type IssuesParametersState = {
+ limit: number
+ totalCount: number
+ page: number
+ hasPrevPage: boolean
+ hasNextPage: boolean
+ filters: SafeIssuesParams['filters']
+ sorting: SafeIssuesParams['sorting']
+ urlParameters: Partial | undefined
+}
+
+type SafeFilterUpdate = Partial<{
+ [K in keyof SafeIssuesParams['filters']]:
+ | SafeIssuesParams['filters'][K]
+ | undefined
+}>
+
+type IssuesParameters = IssuesParametersState & {
+ init: (_args: {
+ params: SafeIssuesParams & { totalCount: number }
+ onStateChange: (queryParams: Partial) => void
+ }) => void
+ setFilters: (filters: SafeFilterUpdate) => void
+ setSorting: (sorting: Partial) => void
+ setLimit: (limit: number) => void
+ onSuccessIssuesFetch: (data: IssuesServerResponse | void) => void
+ nextPage: () => void
+ prevPage: () => void
+ onStateChange: (queryParams: Partial) => void
+ resetFilters: () => void
+ resetSorting: () => void
+}
+
+/**
+ * Derives computed state values like URL parameters and pagination booleans.
+ */
+function deriveState({
+ state,
+ onParametersChange,
+}: {
+ state: Omit<
+ IssuesParametersState,
+ 'urlParameters' | 'hasPrevPage' | 'hasNextPage'
+ >
+ onParametersChange?: (
+ queryParams: Partial,
+ ) => void
+}) {
+ const { page, limit, totalCount } = state
+
+ const hasPrevPage = page > 1
+ const hasNextPage = page * limit < totalCount
+
+ const urlParameters = convertIssuesParamsToQueryParams({
+ page,
+ limit,
+ filters: state.filters,
+ sorting: state.sorting,
+ })
+
+ onParametersChange?.(urlParameters)
+
+ return {
+ ...state,
+ hasPrevPage,
+ hasNextPage,
+ urlParameters,
+ } satisfies IssuesParametersState
+}
+
+export const useIssuesParameters = create((set, get) => ({
+ init: ({ params, onStateChange }) => {
+ set({
+ ...deriveState({
+ state: {
+ page: params.page,
+ totalCount: params.totalCount,
+ limit: params.limit,
+ filters: params.filters,
+ sorting: params.sorting,
+ },
+ }),
+ onStateChange,
+ })
+ },
+ page: 1,
+ totalCount: 0,
+ limit: DEFAULTS_ISSUE_PARAMS.limit,
+ filters: {},
+ sorting: DEFAULTS_ISSUE_PARAMS.sorting,
+ hasPrevPage: false,
+ hasNextPage: false,
+ urlParameters: undefined,
+ onStateChange: (_p) => {},
+
+ setFilters: (filters) => {
+ const state = get()
+ set(
+ deriveState({
+ state: {
+ ...state,
+ filters: { ...state.filters, ...filters },
+ page: 1,
+ },
+ onParametersChange: state.onStateChange,
+ }),
+ )
+ },
+
+ setSorting: (sorting) => {
+ const state = get()
+ set(
+ deriveState({
+ state: {
+ ...state,
+ sorting: { ...state.sorting, ...sorting },
+ },
+ onParametersChange: state.onStateChange,
+ }),
+ )
+ },
+
+ setLimit: (limit) => {
+ const state = get()
+ set(
+ deriveState({
+ state: { ...state, limit },
+ onParametersChange: state.onStateChange,
+ }),
+ )
+ },
+
+ resetFilters: () => {
+ const state = get()
+ set(
+ deriveState({
+ state: { ...state, filters: {} },
+ onParametersChange: state.onStateChange,
+ }),
+ )
+ },
+
+ resetSorting: () => {
+ const state = get()
+ set(
+ deriveState({
+ state: { ...state, sorting: DEFAULTS_ISSUE_PARAMS.sorting },
+ onParametersChange: state.onStateChange,
+ }),
+ )
+ },
+
+ nextPage: () => {
+ const state = get()
+ if (state.page * state.limit >= state.totalCount) return
+
+ const newPage = state.page + 1
+ set(
+ deriveState({
+ state: {
+ ...state,
+ page: newPage,
+ },
+ onParametersChange: state.onStateChange,
+ }),
+ )
+ },
+
+ prevPage: () => {
+ const state = get()
+ if (state.page <= 1) return
+
+ const newPage = Math.max(1, state.page - 1)
+ set(
+ deriveState({
+ state: {
+ ...state,
+ page: newPage,
+ },
+ onParametersChange: state.onStateChange,
+ }),
+ )
+ },
+ onSuccessIssuesFetch: (data) => {
+ if (!data) return
+
+ const state = get()
+ set(
+ deriveState({
+ state: { ...state, totalCount: data.totalCount },
+ onParametersChange: state.onStateChange,
+ }),
+ )
+ },
+}))
diff --git a/packages/constants/package.json b/packages/constants/package.json
index e9d36fe10c..801ecfc90b 100644
--- a/packages/constants/package.json
+++ b/packages/constants/package.json
@@ -27,6 +27,7 @@
"./latte": "./src/latte/index.ts",
"./promptl": "./src/promptl.ts",
"./trigger": "./src/trigger.ts",
+ "./issues": "./src/issues/index.ts",
"./documentTriggers": "./src/documentTriggers/schema.ts",
"./toolSources": "./src/toolSources.ts",
"./users": "./src/users.ts",
@@ -39,6 +40,7 @@
},
"dependencies": {
"ai": "catalog:",
+ "date-fns": "^4.1.0",
"json-schema": "^0.4.0",
"promptl-ai": "catalog:",
"zod": "catalog:"
diff --git a/packages/constants/src/issues/constants.ts b/packages/constants/src/issues/constants.ts
new file mode 100644
index 0000000000..376ba99cfd
--- /dev/null
+++ b/packages/constants/src/issues/constants.ts
@@ -0,0 +1,43 @@
+export const ISSUE_STATUS = {
+ active: 'active', // not resolved, not ignored
+ regressed: 'regressed', // resolved with histogram dates after the resolved date
+ archived: 'archived', // resolved or ignored
+} as const
+
+export type IssueStatus = (typeof ISSUE_STATUS)[keyof typeof ISSUE_STATUS]
+export const ISSUE_SORTS = { relevance: 'relevance' } as const
+
+export type IssueSort = (typeof ISSUE_SORTS)[keyof typeof ISSUE_SORTS]
+export type SafeIssuesParams = {
+ limit: number
+ page: number
+ sorting: {
+ sort: IssueSort
+ sortDirection: 'asc' | 'desc'
+ }
+ filters: {
+ query?: string
+ documentUuid?: string | null
+ status?: IssueStatus
+ firstSeen?: Date
+ lastSeen?: Date
+ }
+}
+
+export const ESCALATING_COUNT_THRESHOLD = 10
+export const ESCALATING_DAYS = 2
+export const NEW_ISSUES_DAYS = 7
+export const RECENT_ISSUES_DAYS = 7
+export const HISTOGRAM_SUBQUERY_ALIAS = 'histogramStats'
+export type QueryParams = { [key: string]: string | string[] | undefined }
+
+export const DEFAULTS_ISSUE_PARAMS = {
+ limit: 25,
+ filters: {
+ status: ISSUE_STATUS.active,
+ },
+ sorting: {
+ sort: 'relevance' as const,
+ sortDirection: 'desc' as const,
+ },
+}
diff --git a/packages/constants/src/issues/index.ts b/packages/constants/src/issues/index.ts
new file mode 100644
index 0000000000..1e1d4e1394
--- /dev/null
+++ b/packages/constants/src/issues/index.ts
@@ -0,0 +1,7 @@
+export * from './constants'
+export { type IssuesFiltersQueryParamsInput } from './parameters'
+export {
+ parseIssuesQueryParams,
+ convertIssuesParamsToQueryParams,
+ buildIssuesCacheKey,
+} from './parseParameters'
diff --git a/packages/constants/src/issues/parameters.ts b/packages/constants/src/issues/parameters.ts
new file mode 100644
index 0000000000..73061a121b
--- /dev/null
+++ b/packages/constants/src/issues/parameters.ts
@@ -0,0 +1,99 @@
+import { z } from 'zod'
+import { parseISO, isValid } from 'date-fns'
+import {
+ ISSUE_STATUS,
+ ISSUE_SORTS,
+ SafeIssuesParams,
+ DEFAULTS_ISSUE_PARAMS,
+ IssueSort,
+ IssueStatus,
+} from './constants'
+
+const MAX_ISSUES_LIMIT = 100
+const LIMIT = DEFAULTS_ISSUE_PARAMS.limit
+
+const ISSUE_STATUS_VALUES = Object.values(ISSUE_STATUS) as [
+ IssueStatus,
+ ...IssueStatus[],
+]
+const ISSUE_SORTS_VALUES = Object.values(ISSUE_SORTS) as [
+ IssueSort,
+ ...IssueSort[],
+]
+
+export const issuesFiltersQueryParamsParser = z
+ .object({
+ documentUuid: z.string().optional().nullable(),
+ query: z.string().optional(),
+ status: z
+ .enum(ISSUE_STATUS_VALUES)
+ .optional()
+ .default(DEFAULTS_ISSUE_PARAMS.filters.status),
+ sort: z
+ .enum(ISSUE_SORTS_VALUES)
+ .optional()
+ .default(DEFAULTS_ISSUE_PARAMS.sorting.sort),
+ sortDirection: z
+ .enum(['asc', 'desc'])
+ .optional()
+ .default(DEFAULTS_ISSUE_PARAMS.sorting.sortDirection),
+ page: z
+ .string()
+ .optional()
+ .transform((val) => {
+ const page = Number(val)
+ return Number.isNaN(page) || page < 1 ? 1 : page
+ })
+ .default(1),
+ pageSize: z
+ .string()
+ .optional()
+ .transform((val) => {
+ const limit = Number(val)
+ if (Number.isNaN(limit) || limit < 1) return LIMIT
+ return Math.min(limit, MAX_ISSUES_LIMIT)
+ }),
+ firstSeen: z
+ .string()
+ .optional()
+ .transform((val) => {
+ if (!val) return undefined
+ const date = parseISO(val)
+ return isValid(date) ? date : undefined
+ }),
+ lastSeen: z
+ .string()
+ .optional()
+ .transform((val) => {
+ if (!val) return undefined
+ const date = parseISO(val)
+ return isValid(date) ? date : undefined
+ }),
+ })
+ .transform((data): SafeIssuesParams => {
+ return {
+ page: Number(data.page),
+ limit: data.pageSize,
+ sorting: {
+ sort: data.sort,
+ sortDirection: data.sortDirection,
+ },
+ filters: {
+ query: data.query,
+ documentUuid: data.documentUuid,
+ status: data.status,
+ firstSeen: data.firstSeen,
+ lastSeen: data.lastSeen,
+ },
+ }
+ })
+
+export type IssuesFiltersQueryParamsOutput = z.output<
+ typeof issuesFiltersQueryParamsParser
+>
+
+// Soft input type where all fields are strings or undefined
+export type IssuesFiltersQueryParamsInput = Record<
+ keyof z.input,
+ string | undefined
+>
diff --git a/packages/constants/src/issues/parseParameters.ts b/packages/constants/src/issues/parseParameters.ts
new file mode 100644
index 0000000000..ec8aa6ed01
--- /dev/null
+++ b/packages/constants/src/issues/parseParameters.ts
@@ -0,0 +1,100 @@
+import {
+ IssuesFiltersQueryParamsInput,
+ IssuesFiltersQueryParamsOutput,
+ issuesFiltersQueryParamsParser,
+} from './parameters'
+import {
+ DEFAULTS_ISSUE_PARAMS,
+ QueryParams,
+ SafeIssuesParams,
+} from './constants'
+
+export function parseIssuesQueryParams({
+ params,
+ defaultFilters,
+}: {
+ params: URLSearchParams | QueryParams
+ defaultFilters?: Pick
+}): IssuesFiltersQueryParamsOutput {
+ const parsed = issuesFiltersQueryParamsParser.parse(
+ params instanceof URLSearchParams ? Object.fromEntries(params) : params,
+ )
+ return {
+ ...parsed,
+ filters: {
+ ...parsed.filters,
+ documentUuid: parsed.filters.documentUuid ?? defaultFilters?.documentUuid,
+ },
+ }
+}
+
+function formatDateLocal(date: Date): string {
+ const year = date.getFullYear()
+ const month = String(date.getMonth() + 1).padStart(2, '0')
+ const day = String(date.getDate()).padStart(2, '0')
+ return `${year}-${month}-${day}`
+}
+
+export function convertIssuesParamsToQueryParams(
+ params: SafeIssuesParams,
+): Partial {
+ const {
+ filters = {},
+ page,
+ limit,
+ sorting = DEFAULTS_ISSUE_PARAMS.sorting,
+ } = params
+
+ const { query, documentUuid, status, firstSeen, lastSeen } = filters
+
+ const rawParams = {
+ query,
+ documentUuid,
+ status,
+ firstSeen: firstSeen ? formatDateLocal(firstSeen) : undefined,
+ lastSeen: lastSeen ? formatDateLocal(lastSeen) : undefined,
+ page: page > 1 ? page.toString() : undefined,
+ pageSize:
+ limit !== DEFAULTS_ISSUE_PARAMS.limit ? limit.toString() : undefined,
+ sort:
+ sorting.sort !== DEFAULTS_ISSUE_PARAMS.sorting.sort
+ ? sorting.sort
+ : undefined,
+ sortDirection:
+ sorting.sortDirection !== DEFAULTS_ISSUE_PARAMS.sorting.sortDirection
+ ? sorting.sortDirection
+ : undefined,
+ }
+
+ return Object.fromEntries(
+ Object.entries(rawParams).filter(([_, value]) => value !== undefined),
+ )
+}
+
+/**
+ * SWR cache key for issues list based on filters and sorting
+ */
+export function buildIssuesCacheKey({
+ projectId,
+ commitUuid,
+ searchParams,
+}: {
+ projectId: number
+ commitUuid: string
+ searchParams: Partial
+}) {
+ return [
+ 'issues',
+ String(projectId),
+ commitUuid,
+ `query:${searchParams.query || '-'}`,
+ `status:${searchParams.status ?? '-'}`,
+ `sort:${searchParams.sort || '-'}`,
+ `sortDirection:${searchParams.sortDirection || '-'}`,
+ `documentUuid:${searchParams.documentUuid || '-'}`,
+ `pageSize:${searchParams.pageSize || '-'}`,
+ `firstSeen:${searchParams.firstSeen || '-'}`,
+ `lastSeen:${searchParams.lastSeen || '-'}`,
+ ...(searchParams.page ? [`page:${searchParams.page}`] : []),
+ ].join('|')
+}
diff --git a/packages/core/scripts/disposables/README.md b/packages/core/scripts/disposables/README.md
new file mode 100644
index 0000000000..ffb8642adc
--- /dev/null
+++ b/packages/core/scripts/disposables/README.md
@@ -0,0 +1,6 @@
+# What are disposables?
+
+Disposable scripts are things a developer do during development of a feature and
+can share with other developers to make their life easier. They are not part of the
+final product, but rather tools to help with the development process.
+After the feature is complete, these scripts can be disposed of, hence the name
diff --git a/packages/core/src/repositories/commitsRepository/index.ts b/packages/core/src/repositories/commitsRepository/index.ts
index 3e7a2bb917..46eb070ec0 100644
--- a/packages/core/src/repositories/commitsRepository/index.ts
+++ b/packages/core/src/repositories/commitsRepository/index.ts
@@ -9,6 +9,7 @@ import {
inArray,
not,
or,
+ lte,
} from 'drizzle-orm'
import { type Commit } from '../../schema/models/types/Commit'
@@ -134,6 +135,28 @@ export class CommitsRepository extends RepositoryLegacy<
return Result.ok(result[0]!)
}
+ /**
+ * Get all the commits that are merged before our commit
+ * and also include our commit even it's not merged yet
+ */
+ async getCommitsHistory({ commit }: { commit: Commit }) {
+ const condition = commit.mergedAt
+ ? and(
+ isNotNull(this.scope.mergedAt),
+ lte(this.scope.mergedAt, commit.mergedAt),
+ )
+ : or(isNotNull(this.scope.mergedAt), eq(this.scope.id, commit.id))
+
+ return (
+ this.db
+ .select()
+ .from(this.scope)
+ .where(and(eq(this.scope.projectId, commit.projectId), condition))
+ // TODO:: Is draft on top or bottom?
+ .orderBy(desc(this.scope.mergedAt))
+ )
+ }
+
getCommitsWithDocumentChanges({
project,
documentUuid,
diff --git a/packages/core/src/repositories/index.ts b/packages/core/src/repositories/index.ts
index 4c1d6d3376..3ac1279d3b 100644
--- a/packages/core/src/repositories/index.ts
+++ b/packages/core/src/repositories/index.ts
@@ -16,6 +16,8 @@ export * from './experimentsRepository'
export * from './featuresRepository'
export * from './grantsRepository'
export * from './integrationsRepository'
+export * from './issuesRepository'
+export * from './issueHistogramsRepository'
export * from './latitudeApiKeysRepository'
export * from './latteRequestsRepository'
export * from './latteThreadsRepository'
diff --git a/packages/core/src/repositories/issueHistogramsRepository.ts b/packages/core/src/repositories/issueHistogramsRepository.ts
new file mode 100644
index 0000000000..df4a0ba2ec
--- /dev/null
+++ b/packages/core/src/repositories/issueHistogramsRepository.ts
@@ -0,0 +1,117 @@
+import { and, eq, getTableColumns, inArray, SQL, sql } from 'drizzle-orm'
+import { endOfDay, startOfDay } from 'date-fns'
+import { type Issue } from '../schema/models/types/Issue'
+import RepositoryLegacy from './repository'
+import { issueHistograms } from '../schema/models/issueHistograms'
+import {
+ ESCALATING_DAYS,
+ RECENT_ISSUES_DAYS,
+ HISTOGRAM_SUBQUERY_ALIAS,
+ SafeIssuesParams,
+} from '@latitude-data/constants/issues'
+
+const tt = getTableColumns(issueHistograms)
+type IssueFilters = SafeIssuesParams['filters']
+
+export class IssueHistogramsRepository extends RepositoryLegacy<
+ typeof tt,
+ Issue
+> {
+ get scope() {
+ return this.db
+ .select(tt)
+ .from(issueHistograms)
+ .where(eq(issueHistograms.workspaceId, this.workspaceId))
+ .as('issuesHistogramsScope')
+ }
+
+ /**
+ * NOTE: Developer is responsible of passing the right commit IDs
+ */
+ getHistogramStatsSubquery({
+ commitIds,
+ filters,
+ }: {
+ commitIds: number[]
+ filters: IssueFilters
+ }) {
+ const havingConditions = this.buildHavingConditions({ filters })
+ const baseQuery = this.db
+ .select({
+ issueId: issueHistograms.issueId,
+ recentCount: sql
+ .raw(
+ `
+ COALESCE(SUM(
+ CASE
+ WHEN "date" >= CURRENT_DATE - INTERVAL '` +
+ RECENT_ISSUES_DAYS +
+ ` days'
+ THEN "count"
+ ELSE 0
+ END
+ ), 0)
+ `,
+ )
+ .as('recentCount'),
+ firstSeenDate: sql`MIN(${issueHistograms.date})`.as(
+ 'firstSeenDate',
+ ),
+ lastSeenDate: sql`MAX(${issueHistograms.date})`.as(
+ 'lastSeenDate',
+ ),
+ escalatingCount: sql
+ .raw(
+ `
+ COALESCE(SUM(
+ CASE
+ WHEN "date" >= CURRENT_DATE - INTERVAL '` +
+ ESCALATING_DAYS +
+ ` days'
+ THEN "count"
+ ELSE 0
+ END
+ ), 0)
+ `,
+ )
+ .as('escalatingCount'),
+ totalCount: sql`COALESCE(SUM(${issueHistograms.count}), 0)`.as(
+ 'totalCount',
+ ),
+ })
+ .from(issueHistograms)
+ .where(
+ and(
+ eq(issueHistograms.workspaceId, this.workspaceId),
+ inArray(issueHistograms.commitId, commitIds),
+ ),
+ )
+ .groupBy(issueHistograms.issueId)
+
+ if (havingConditions.length === 0) {
+ return baseQuery.as(HISTOGRAM_SUBQUERY_ALIAS)
+ }
+
+ return baseQuery
+ .having(and(...havingConditions))
+ .as(HISTOGRAM_SUBQUERY_ALIAS)
+ }
+
+ private buildHavingConditions({ filters }: { filters: IssueFilters }) {
+ const conditions: SQL[] = []
+
+ if (filters.firstSeen) {
+ const fromStartOfDay = startOfDay(filters.firstSeen)
+ // Use actual aggregate expression, not the alias
+ conditions.push(sql`MIN(${issueHistograms.date}) >= ${fromStartOfDay}`)
+ }
+
+ if (filters.lastSeen) {
+ const toEndOfDay = endOfDay(filters.lastSeen)
+ // Use actual aggregate expression, not the alias
+ conditions.push(sql`MAX(${issueHistograms.date}) <= ${toEndOfDay}`)
+ }
+
+ return conditions
+ }
+}
diff --git a/packages/core/src/repositories/issuesRepository.ts b/packages/core/src/repositories/issuesRepository.ts
new file mode 100644
index 0000000000..4e37e2b26c
--- /dev/null
+++ b/packages/core/src/repositories/issuesRepository.ts
@@ -0,0 +1,267 @@
+import {
+ and,
+ asc,
+ desc,
+ eq,
+ getTableColumns,
+ isNull,
+ like,
+ or,
+ sql,
+ SQL,
+} from 'drizzle-orm'
+import { type Issue } from '../schema/models/types/Issue'
+import { type Project } from '../schema/models/types/Project'
+import { type Commit } from '../schema/models/types/Commit'
+import { issues } from '../schema/models/issues'
+import { Result } from '../lib/Result'
+import Repository from './repositoryV2'
+import { IssueHistogramsRepository } from './issueHistogramsRepository'
+import { CommitsRepository } from './commitsRepository'
+import {
+ SafeIssuesParams,
+ IssueSort,
+ ESCALATING_COUNT_THRESHOLD,
+ HISTOGRAM_SUBQUERY_ALIAS,
+} from '@latitude-data/constants/issues'
+
+const tt = getTableColumns(issues)
+
+type IssueFilters = SafeIssuesParams['filters']
+type Sorting = SafeIssuesParams['sorting']
+
+type FilteringArguments = {
+ project: Project
+ commit: Commit
+ filters: IssueFilters
+ sorting: Sorting
+ page: number
+ limit: number
+}
+
+export class IssuesRepository extends Repository {
+ get scopeFilter() {
+ return eq(issues.workspaceId, this.workspaceId)
+ }
+
+ get scope() {
+ return this.db.select(tt).from(issues).where(this.scopeFilter).$dynamic()
+ }
+
+ /**
+ * Offset-based pagination for issues with filtering and sorting.
+ */
+ async fetchIssuesFiltered({
+ project,
+ commit,
+ filters,
+ sorting: { sort, sortDirection },
+ page,
+ limit,
+ }: FilteringArguments) {
+ const offset = (page - 1) * limit
+ const whereConditions = this.buildWhereConditions({ project, filters })
+ const orderByClause = this.buildOrderByClause({
+ sort,
+ sortDirection,
+ })
+ const results = await this.fetchIssues({
+ commit,
+ filters,
+ where: whereConditions,
+ orderBy: orderByClause,
+ limit,
+ offset,
+ })
+ const totalResult = await this.fetchIssuesCount({
+ project,
+ commit,
+ filters,
+ })
+ const totalCount = totalResult.unwrap()
+
+ return Result.ok({
+ issues: results,
+ page,
+ limit,
+ totalCount,
+ })
+ }
+
+ private async fetchIssues({
+ commit,
+ where,
+ filters,
+ orderBy,
+ limit,
+ offset,
+ }: {
+ commit: Commit
+ filters: IssueFilters
+ where: SQL[]
+ orderBy: SQL[]
+ limit: number
+ offset: number
+ }) {
+ const commitIds = await this.getCommitIds({ commit })
+ const subquery = this.buildHistogramSubquery({ commitIds, filters })
+ // Make listing lighter by excluding description field
+ const { description: _, ...issueColumns } = tt
+
+ const query = this.db
+ .select({
+ ...issueColumns,
+ recentCount: subquery.recentCount,
+ totalCount: subquery.totalCount,
+ firstSeenDate: subquery.firstSeenDate,
+ lastSeenDate: subquery.lastSeenDate,
+ escalatingCount: subquery.escalatingCount,
+ isNew:
+ sql`(${issues.createdAt} >= NOW() - INTERVAL '7 days')`.as(
+ 'isNew',
+ ),
+ isResolved: sql`(${issues.resolvedAt} IS NOT NULL)`.as(
+ 'isResolved',
+ ),
+ isEscalating: sql`(
+ CASE
+ WHEN ${subquery.escalatingCount} > ${ESCALATING_COUNT_THRESHOLD}
+ THEN true
+ ELSE false
+ END
+ )`.as('isEscalating'),
+ isIgnored: sql`(${issues.ignoredAt} IS NOT NULL)`.as(
+ 'isIgnored',
+ ),
+ })
+ .from(issues)
+ .innerJoin(subquery, eq(subquery.issueId, issues.id))
+ .where(and(...where))
+ .orderBy(...orderBy)
+ .limit(limit)
+ .offset(offset)
+
+ return await query
+ }
+
+ /**
+ * Get total count of filtered issues.
+ * Used for calculating total pages for offset pagination.
+ */
+ private async fetchIssuesCount({
+ project,
+ commit,
+ filters,
+ }: Omit) {
+ const whereConditions = this.buildWhereConditions({ project, filters })
+ const commitIds = await this.getCommitIds({ commit })
+ const subquery = this.buildHistogramSubquery({ commitIds, filters })
+
+ const innerQuery = this.db
+ .select({ issueId: issues.id })
+ .from(issues)
+ .innerJoin(subquery, eq(subquery.issueId, issues.id))
+ .where(and(...whereConditions))
+ .as('filteredIssues')
+
+ const query = this.db
+ .select({ count: sql`COUNT(*)::integer` })
+ .from(innerQuery)
+
+ const result = await query
+ return Result.ok(result[0]?.count ?? 0)
+ }
+
+ private buildHistogramSubquery({
+ commitIds,
+ filters,
+ }: {
+ commitIds: number[]
+ filters: IssueFilters
+ }) {
+ const histogramRepo = new IssueHistogramsRepository(
+ this.workspaceId,
+ this.db,
+ )
+ return histogramRepo.getHistogramStatsSubquery({
+ commitIds,
+ filters,
+ })
+ }
+
+ private buildWhereConditions({
+ project,
+ filters,
+ }: {
+ project: Project
+ filters: IssueFilters
+ }) {
+ const conditions: SQL[] = [
+ this.scopeFilter,
+ eq(issues.projectId, project.id),
+ ]
+
+ // Use documentUuid filter if provided, otherwise use commit.mainDocumentUuid as default
+ if (filters.documentUuid) {
+ conditions.push(eq(issues.documentUuid, filters.documentUuid))
+ }
+
+ if (filters.query && filters.query.trim().length > 0) {
+ conditions.push(like(issues.title, `%${filters.query}%`))
+ }
+
+ // Handle status filtering based on new tab system
+ const status = filters.status || 'active' // Default to active
+
+ switch (status) {
+ case 'active':
+ // Active: not resolved and not ignored
+ conditions.push(isNull(issues.resolvedAt))
+ conditions.push(isNull(issues.ignoredAt))
+ break
+ case 'archived':
+ // Archived: resolved or ignored
+ conditions.push(
+ or(
+ sql`${issues.resolvedAt} IS NOT NULL`,
+ sql`${issues.ignoredAt} IS NOT NULL`,
+ )!,
+ )
+ break
+ case 'regressed':
+ // Regressed: resolved but with histogram data after resolved date
+ // This will be handled in HAVING clause since it requires histogram data
+ conditions.push(
+ ...[
+ sql`${issues.resolvedAt} IS NOT NULL`,
+ isNull(issues.ignoredAt),
+ sql`${sql.raw(`"${HISTOGRAM_SUBQUERY_ALIAS}"."lastSeenDate"`)} > ${issues.resolvedAt}`,
+ ],
+ )
+ break
+ }
+
+ return conditions
+ }
+
+ private buildOrderByClause({
+ sortDirection,
+ }: {
+ sort: IssueSort
+ sortDirection: Sorting['sortDirection']
+ }) {
+ const dir = sortDirection === 'asc' ? asc : desc
+ return [
+ dir(sql.raw(`"${HISTOGRAM_SUBQUERY_ALIAS}"."recentCount"`)),
+ dir(sql.raw(`"${HISTOGRAM_SUBQUERY_ALIAS}"."lastSeenDate"`)),
+ dir(issues.id),
+ ]
+ }
+
+ private async getCommitIds({ commit }: { commit: Commit }) {
+ const commitsRepo = new CommitsRepository(this.workspaceId, this.db)
+ const commits = await commitsRepo.getCommitsHistory({ commit })
+ const commitIds = commits.map((c: { id: number }) => c.id)
+ return commitIds
+ }
+}
diff --git a/packages/core/src/schema/models/types/Issue.ts b/packages/core/src/schema/models/types/Issue.ts
new file mode 100644
index 0000000000..fb81f3cf40
--- /dev/null
+++ b/packages/core/src/schema/models/types/Issue.ts
@@ -0,0 +1,5 @@
+import { type InferSelectModel } from 'drizzle-orm'
+
+import { issues } from '../issues'
+
+export type Issue = InferSelectModel
diff --git a/packages/core/src/schema/models/types/IssueHistogram.ts b/packages/core/src/schema/models/types/IssueHistogram.ts
new file mode 100644
index 0000000000..501d9c3e4e
--- /dev/null
+++ b/packages/core/src/schema/models/types/IssueHistogram.ts
@@ -0,0 +1,5 @@
+import { type InferSelectModel } from 'drizzle-orm'
+
+import { issueHistograms } from '../issueHistograms'
+
+export type IssueHistogram = InferSelectModel
diff --git a/packages/core/src/services/evaluationsV2/annotate.ts b/packages/core/src/services/evaluationsV2/annotate.ts
index 34f3314675..0ce90de0eb 100644
--- a/packages/core/src/services/evaluationsV2/annotate.ts
+++ b/packages/core/src/services/evaluationsV2/annotate.ts
@@ -159,6 +159,10 @@ export async function annotateEvaluationV2<
value = { error: { message: (error as Error).message } }
}
+ // TODO: Validate the result is not passed and not errored before assigning issue
+ // TODO: Check issue belongs to document
+ // TODO: upsert histogram add or remove a count
+
// TODO: We are stepping out of the db instance. This service should accept an instance of Transaction instead.
const transaction = new Transaction()
return await transaction.call(
diff --git a/packages/core/src/services/issueHistograms/createBulk.ts b/packages/core/src/services/issueHistograms/createBulk.ts
new file mode 100644
index 0000000000..a6b9d19318
--- /dev/null
+++ b/packages/core/src/services/issueHistograms/createBulk.ts
@@ -0,0 +1,38 @@
+import { format } from 'date-fns'
+import { type Workspace } from '../../schema/models/types/Workspace'
+import { type Issue } from '../../schema/models/types/Issue'
+import { Result } from '../../lib/Result'
+import Transaction from '../../lib/Transaction'
+import { issueHistograms } from '../../schema/models/issueHistograms'
+
+export type IssueHistogramData = {
+ issue: Issue
+ commitId: number
+ date: Date
+ count: number
+}
+
+export async function createIssueHistogramsBulk(
+ {
+ workspace,
+ histograms,
+ }: {
+ workspace: Workspace
+ histograms: IssueHistogramData[]
+ },
+ transaction = new Transaction(),
+) {
+ return transaction.call(async (tx) => {
+ const values = histograms.map(({ issue, commitId, date, count }) => ({
+ workspaceId: workspace.id,
+ issueId: issue.id,
+ commitId,
+ date: format(date, 'yyyy-MM-dd'),
+ count,
+ }))
+
+ const results = await tx.insert(issueHistograms).values(values).returning()
+
+ return Result.ok(results)
+ })
+}
diff --git a/packages/core/src/services/issues/create.ts b/packages/core/src/services/issues/create.ts
new file mode 100644
index 0000000000..d63cf3f486
--- /dev/null
+++ b/packages/core/src/services/issues/create.ts
@@ -0,0 +1,40 @@
+import { type Workspace } from '../../schema/models/types/Workspace'
+import { type Project } from '../../schema/models/types/Project'
+import { Result } from '../../lib/Result'
+import Transaction from '../../lib/Transaction'
+import { issues } from '../../schema/models/issues'
+
+export async function createIssue(
+ {
+ workspace,
+ project,
+ documentUuid,
+ title,
+ description,
+ createdAt,
+ }: {
+ workspace: Workspace
+ project: Project
+ documentUuid: string
+ title: string
+ description: string
+ createdAt?: Date
+ },
+ transaction = new Transaction(),
+) {
+ return transaction.call(async (tx) => {
+ const result = await tx
+ .insert(issues)
+ .values({
+ createdAt: createdAt ?? new Date(),
+ workspaceId: workspace.id,
+ projectId: project.id,
+ documentUuid,
+ title,
+ description,
+ })
+ .returning()
+
+ return Result.ok(result[0]!)
+ })
+}
diff --git a/packages/core/src/tests/factories/issues.ts b/packages/core/src/tests/factories/issues.ts
new file mode 100644
index 0000000000..425ed3f64d
--- /dev/null
+++ b/packages/core/src/tests/factories/issues.ts
@@ -0,0 +1,81 @@
+import { faker } from '@faker-js/faker'
+import { type Workspace } from '../../schema/models/types/Workspace'
+import { type Project } from '../../schema/models/types/Project'
+import { createIssue as createIssueService } from '../../services/issues/create'
+import {
+ createIssueHistogramsBulk,
+ type IssueHistogramData,
+} from '../../services/issueHistograms/createBulk'
+import { createWorkspace, type ICreateWorkspace } from './workspaces'
+import { createProject, type ICreateProject } from './projects'
+import { IssueHistogram } from '../../schema/models/types/IssueHistogram'
+
+export type ICreateIssue = {
+ createAt: Date
+ workspace?: Workspace | ICreateWorkspace
+ project?: Project | ICreateProject
+ documentUuid?: string
+ title?: string
+ description?: string
+ histograms?: IssueHistogramData[]
+}
+
+export async function createIssue(issueData: Partial = {}) {
+ const workspaceData = issueData.workspace ?? {}
+ let workspace: Workspace
+ let project: Project
+
+ if ('id' in workspaceData) {
+ workspace = workspaceData as Workspace
+ } else {
+ const newWorkspace = await createWorkspace(workspaceData)
+ workspace = newWorkspace.workspace
+ }
+
+ const projectData = issueData.project ?? {}
+ if ('id' in projectData) {
+ project = projectData as Project
+ } else {
+ const newProject = await createProject({ workspace, ...projectData })
+ project = newProject.project
+ }
+
+ const documentUuid = issueData.documentUuid ?? faker.string.uuid()
+ const title = issueData.title ?? faker.lorem.sentence()
+ const description = issueData.description ?? faker.lorem.paragraph()
+
+ const result = await createIssueService({
+ workspace,
+ project,
+ documentUuid,
+ title,
+ description,
+ createdAt: issueData.createAt,
+ })
+
+ const issue = result.unwrap()
+ let histograms: IssueHistogram[] = []
+
+ // Create histograms if provided
+ if (issueData.histograms && issueData.histograms.length > 0) {
+ const histogramResult = await createIssueHistogramsBulk({
+ workspace,
+ histograms: issueData.histograms.map((histogram) => ({
+ ...histogram,
+ issue,
+ })),
+ })
+
+ if (histogramResult.error) {
+ throw histogramResult.error
+ }
+ histograms = histogramResult.value
+ }
+
+ return {
+ issue,
+ workspace,
+ project,
+ histograms,
+ }
+}
diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json
index 7623798c70..8d0b7425c1 100644
--- a/packages/web-ui/package.json
+++ b/packages/web-ui/package.json
@@ -174,6 +174,7 @@
"import": "./src/ds/molecules/TwoColumnSelect/index.tsx"
},
"./molecules/NumeredList": "./src/ds/molecules/NumeredList/index.tsx",
+ "./molecules/RadioToggleInput": "./src/ds/molecules/RadioToggleInput/index.tsx",
"./molecules/RangeBadge": "./src/ds/molecules/RangeBadge/index.tsx",
"./molecules/SelectableCard": "./src/ds/molecules/SelectableCard/index.tsx",
"./molecules/SelectableSwitch": "./src/ds/molecules/SelectableSwitch/index.tsx",
diff --git a/packages/web-ui/src/ds/atoms/DatePicker/Range/index.tsx b/packages/web-ui/src/ds/atoms/DatePicker/Range/index.tsx
index a2bbaa7d76..55fa96f5fc 100644
--- a/packages/web-ui/src/ds/atoms/DatePicker/Range/index.tsx
+++ b/packages/web-ui/src/ds/atoms/DatePicker/Range/index.tsx
@@ -4,31 +4,36 @@ import { format } from 'date-fns'
import { useCallback, useMemo, useState } from 'react'
import type { RelativeDate } from '@latitude-data/core/constants'
-import { DateRange } from 'react-day-picker'
+import { DateRange as ReactDatePickerRange } from 'react-day-picker'
import { cn } from '../../../../lib/utils'
import { Button } from '../../Button'
-import { Popover } from '../../Popover'
+import { Popover, PopoverContentProps } from '../../Popover'
import { Select } from '../../Select'
import { Calendar } from '../Primitives'
import { usePresets } from './usePresets'
export type DatePickerMode = 'single' | 'range'
+export type DateRange = ReactDatePickerRange
function renderLabel({
range,
selectedPreset,
placeholder,
+ singleDatePrefix,
}: {
range: DateRange | undefined
placeholder: string
selectedPreset?: { label: string; value: RelativeDate }
+ singleDatePrefix?: string
}) {
if (selectedPreset) return { label: selectedPreset.label, selected: true }
if (range?.from) {
const rangeSelection = range.to
? `${format(range.from, 'LLL dd, y')} - ${format(range.to, 'LLL dd, y')}`
- : format(range.from, 'LLL dd, y')
+ : singleDatePrefix
+ ? `${singleDatePrefix} ${format(range.from, 'LLL dd, y')}`
+ : format(range.from, 'LLL dd, y')
return { label: rangeSelection, selected: true }
}
@@ -43,6 +48,8 @@ export function DatePickerRange({
placeholder = 'Pick a date',
closeOnPresetSelect = true,
disabled = false,
+ align = 'start',
+ singleDatePrefix,
}: {
showPresets?: boolean
initialRange?: DateRange
@@ -51,6 +58,8 @@ export function DatePickerRange({
closeOnPresetSelect?: boolean
placeholder?: string
disabled?: boolean
+ align?: PopoverContentProps['align']
+ singleDatePrefix?: string
}) {
const [open, setOpen] = useState(false)
const [range, setRange] = useState(initialRange)
@@ -59,8 +68,8 @@ export function DatePickerRange({
showPresets,
})
const selection = useMemo(
- () => renderLabel({ range, selectedPreset, placeholder }),
- [range, selectedPreset, placeholder],
+ () => renderLabel({ range, selectedPreset, placeholder, singleDatePrefix }),
+ [range, selectedPreset, placeholder, singleDatePrefix],
)
const onPresetSelect = useCallback(
@@ -100,7 +109,7 @@ export function DatePickerRange({
ellipsis
variant='outline'
className={cn(
- 'w-72 justify-start text-left font-normal',
+ 'justify-start text-left font-normal',
!range && 'text-muted-foreground',
)}
iconProps={{
@@ -119,7 +128,7 @@ export function DatePickerRange({
diff --git a/packages/web-ui/src/ds/atoms/Icons/index.tsx b/packages/web-ui/src/ds/atoms/Icons/index.tsx
index 244f38d193..c2dde7b3a7 100644
--- a/packages/web-ui/src/ds/atoms/Icons/index.tsx
+++ b/packages/web-ui/src/ds/atoms/Icons/index.tsx
@@ -152,6 +152,7 @@ import {
Zap,
SquareDashed,
Brush,
+ ShieldAlertIcon,
} from 'lucide-react'
import { MouseEvent } from 'react'
@@ -211,6 +212,7 @@ import Wordpress from './custom-icons/logos/Wordpress'
import YepCode from './custom-icons/logos/YepCode'
const Icons = {
+ shieldAlert: ShieldAlertIcon,
addCircle: CirclePlus,
addSquare: SquarePlus,
squareChart: SquareChartGantt,
diff --git a/packages/web-ui/src/ds/atoms/Select/Primitives/index.tsx b/packages/web-ui/src/ds/atoms/Select/Primitives/index.tsx
index de983dc2c7..41872ac23a 100644
--- a/packages/web-ui/src/ds/atoms/Select/Primitives/index.tsx
+++ b/packages/web-ui/src/ds/atoms/Select/Primitives/index.tsx
@@ -205,12 +205,15 @@ const SelectScrollDownButton = forwardRef<
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
+export type SelectContentProps = ComponentPropsWithoutRef<
+ typeof SelectPrimitive.Content
+> & {
+ autoScroll?: boolean
+ maxHeightAuto?: boolean
+}
const SelectContent = forwardRef<
ElementRef,
- ComponentPropsWithoutRef & {
- autoScroll?: boolean
- maxHeightAuto?: boolean
- }
+ SelectContentProps
>(
(
{
@@ -228,7 +231,7 @@ const SelectContent = forwardRef<
ref={ref}
position={position}
className={cn(
- 'min-w-[8rem] relative z-50 overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-md',
+ 'min-w-52 relative z-50 overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-md',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
diff --git a/packages/web-ui/src/ds/atoms/Select/index.tsx b/packages/web-ui/src/ds/atoms/Select/index.tsx
index 43b22c2253..e3498befca 100644
--- a/packages/web-ui/src/ds/atoms/Select/index.tsx
+++ b/packages/web-ui/src/ds/atoms/Select/index.tsx
@@ -9,6 +9,7 @@ import { Skeleton } from '../Skeleton'
import { Text } from '../Text'
import {
SelectContent,
+ type SelectContentProps,
SelectGroup,
SelectItem,
SelectRoot,
@@ -38,29 +39,30 @@ export function Options({ options }: { options: SelectOption[] }) {
export type SelectProps = Omit<
FormFieldProps,
'children'
-> & {
- name: string
- options: SelectOption[]
- defaultValue?: V
- value?: V
- trigger?: ReactNode
- placeholder?: string
- loading?: boolean
- disabled?: boolean
- required?: boolean
- onChange?: (value: V) => void
- width?: 'auto' | 'full'
- size?: 'small' | 'default'
- removable?: boolean
- searchable?: boolean
- open?: boolean
- onOpenChange?: (open: boolean) => void
- footerAction?: {
- label: string
- icon?: IconName
- onClick: () => void
+> &
+ Pick & {
+ name: string
+ options: SelectOption[]
+ defaultValue?: V
+ value?: V
+ trigger?: ReactNode
+ placeholder?: string
+ loading?: boolean
+ disabled?: boolean
+ required?: boolean
+ onChange?: (value: V) => void
+ width?: 'auto' | 'full'
+ size?: 'small' | 'default'
+ removable?: boolean
+ searchable?: boolean
+ open?: boolean
+ onOpenChange?: (open: boolean) => void
+ footerAction?: {
+ label: string
+ icon?: IconName
+ onClick: () => void
+ }
}
-}
export function Select({
name,
label,
@@ -77,6 +79,7 @@ export function Select({
onChange,
width = 'full',
size = 'default',
+ align = 'start',
loading = false,
disabled = false,
required = false,
@@ -147,7 +150,7 @@ export function Select({
/>
)}
-
+
{searchable ? (
options={options}
diff --git a/packages/web-ui/src/ds/molecules/PaginatedSelect/index.tsx b/packages/web-ui/src/ds/molecules/PaginatedSelect/index.tsx
index c33537da36..7217ffd630 100644
--- a/packages/web-ui/src/ds/molecules/PaginatedSelect/index.tsx
+++ b/packages/web-ui/src/ds/molecules/PaginatedSelect/index.tsx
@@ -34,7 +34,7 @@ import {
} from '../../atoms/Select'
import { useDebouncedCallback } from 'use-debounce'
-export type PaginatedSelectProps = Omit<
+type PaginatedSelectProps = Omit<
FormFieldProps,
'children'
> & {
diff --git a/packages/web-ui/src/ds/molecules/RadioToggleInput/index.tsx b/packages/web-ui/src/ds/molecules/RadioToggleInput/index.tsx
new file mode 100644
index 0000000000..12d6089297
--- /dev/null
+++ b/packages/web-ui/src/ds/molecules/RadioToggleInput/index.tsx
@@ -0,0 +1,131 @@
+'use client'
+import {
+ ChangeEvent,
+ ReactNode,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from 'react'
+
+import { cn } from '../../../lib/utils'
+import { Text } from '../../atoms/Text'
+
+export type RadioToggleOption = {
+ label: ReactNode | string
+ value: T
+ disabled?: boolean
+}
+
+function LabelText({
+ isSelected,
+ children,
+}: {
+ isSelected: boolean
+ children: ReactNode | string
+}) {
+ if (typeof children === 'string') {
+ return (
+
+ {children}
+
+ )
+ }
+ return <>{children}>
+}
+
+export function RadioToggleInput({
+ name,
+ options,
+ value,
+ disabled,
+ onChange,
+}: {
+ name: string
+ options: RadioToggleOption[]
+ value?: T
+ onChange?: (value: T) => void
+ disabled?: boolean
+}) {
+ const selectedOptionButtonRef = useRef(null)
+ const selectedOptionBackgroundRef = useRef(null)
+ const [selected, setSelected] = useState(value)
+
+ useEffect(() => {
+ setSelected(value)
+ }, [value])
+
+ const handleChange = useCallback(
+ (event: ChangeEvent) => {
+ const newValue = event.target.value as T
+ setSelected(newValue)
+ onChange?.(newValue)
+ },
+ [onChange],
+ )
+
+ useEffect(() => {
+ const background = selectedOptionBackgroundRef.current
+ if (!background) return
+
+ const button = selectedOptionButtonRef.current
+ if (!button) {
+ background.style.display = 'none'
+ return
+ }
+
+ const updateBackgroundPosition = () => {
+ background.style.left = `${button.offsetLeft}px`
+ background.style.width = `${button.offsetWidth}px`
+ background.style.display = 'block'
+ }
+
+ updateBackgroundPosition()
+
+ const resizeObserver = new ResizeObserver(updateBackgroundPosition)
+ resizeObserver.observe(button)
+
+ return () => resizeObserver.disconnect()
+ }, [selected])
+
+ return (
+
+
+ {options.map((option) => {
+ const isSelected = selected === option.value
+ return (
+
+ )
+ })}
+
+ )
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index faafdf7ad2..30f9a96825 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -692,6 +692,9 @@ importers:
ai:
specifier: 'catalog:'
version: 5.0.44(zod@4.1.8)
+ date-fns:
+ specifier: ^4.1.0
+ version: 4.1.0
json-schema:
specifier: ^0.4.0
version: 0.4.0