Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions backend/apps/github/api/internal/nodes/issue.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
"""GitHub issue GraphQL node."""

from datetime import datetime

import strawberry
import strawberry_django
from django.db.models import Exists, OuterRef
from strawberry.types import Info

from apps.github.api.internal.nodes.pull_request import PullRequestNode
from apps.github.api.internal.nodes.user import UserNode
Expand Down Expand Up @@ -71,3 +74,11 @@ def interested_users(self, root: Issue) -> list[UserNode]:
"user__login"
)
]

@strawberry.field
def task_deadline(self, root: Issue, info: Info) -> datetime | None:
"""Return the deadline for the latest assigned task linked to this issue."""
mapping = getattr(info.context, "task_deadlines_by_issue", None)
if mapping is None:
return None
return mapping.get(root.number)
50 changes: 45 additions & 5 deletions backend/apps/mentorship/api/internal/nodes/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from datetime import datetime

import strawberry
from strawberry.types import Info

from apps.common.utils import normalize_limit
from apps.github.api.internal.nodes.issue import IssueNode
Expand Down Expand Up @@ -79,9 +80,37 @@ def project_name(self) -> str | None:

@strawberry.field
def issues(
self, limit: int = 20, offset: int = 0, label: str | None = None
self, info: Info, limit: int = 20, offset: int = 0, label: str | None = None
) -> list[IssueNode]:
"""Return paginated issues linked to this module, optionally filtered by label."""
info.context.current_module = self

# BULK load data
deadline_rows = (
Task.objects.filter(module=self, deadline_at__isnull=False)
.order_by("issue__number", "-assigned_at")
.values("issue__number", "deadline_at")
)
assigned_rows = (
Task.objects.filter(module=self, assigned_at__isnull=False)
.order_by("issue__number", "-assigned_at")
.values("issue__number", "assigned_at")
)

deadline_map = {}
assigned_map = {}

for row in deadline_rows:
num = row["issue__number"]
if num not in deadline_map:
deadline_map[num] = row["deadline_at"]
for row in assigned_rows:
num = row["issue__number"]
if num not in assigned_map:
assigned_map[num] = row["assigned_at"]

info.context.task_deadlines_by_issue = deadline_map
info.context.task_assigned_at_by_issue = assigned_map
if (normalized_limit := normalize_limit(limit, MAX_LIMIT)) is None:
return []

Expand Down Expand Up @@ -116,8 +145,10 @@ def available_labels(self) -> list[str]:
return sorted(label_names)

@strawberry.field
def issue_by_number(self, number: int) -> IssueNode | None:
def issue_by_number(self, info: Info, number: int) -> IssueNode | None:
"""Return a single issue by its GitHub number within this module's linked issues."""
info.context.current_module = self

return (
self.issues.select_related("repository", "author")
.prefetch_related("assignees", "labels")
Expand All @@ -139,8 +170,13 @@ def interested_users(self, issue_number: int) -> list[UserNode]:
return [i.user for i in interests]

@strawberry.field
def task_deadline(self, issue_number: int) -> datetime | None:
def task_deadline(self, info: Info, issue_number: int) -> datetime | None:
"""Return the deadline for the latest assigned task linked to this module and issue."""
mapping = getattr(info.context, "task_deadlines_by_issue", None)
if mapping is not None:
return mapping.get(issue_number)

# fallback (single issue query)
return (
Task.objects.filter(
module=self,
Expand All @@ -153,8 +189,12 @@ def task_deadline(self, issue_number: int) -> datetime | None:
)

@strawberry.field
def task_assigned_at(self, issue_number: int) -> datetime | None:
"""Return the latest assignment time for tasks linked to this module and issue number."""
def task_assigned_at(self, info: Info, issue_number: int) -> datetime | None:
"""Return the latest assignment time for tasks linked to this module and issue."""
mapping = getattr(info.context, "task_assigned_at_by_issue", None)
if mapping is not None:
return mapping.get(issue_number)

return (
Task.objects.filter(
module=self,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ def test_issue_node_fields(self):
"pull_requests",
"repository_name",
"state",
"task_deadline",
"title",
"url",
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,20 @@ const ModuleIssueDetailsPage = () => {
const formatDeadline = (deadline: string | null) => {
if (!deadline) return { text: 'No deadline set', color: 'text-gray-600 dark:text-gray-300' }

const now = new Date()

const todayUTC = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())

const deadlineDate = new Date(deadline)
const today = new Date()

const deadlineUTC = new Date(
const deadlineUTC = Date.UTC(
deadlineDate.getUTCFullYear(),
deadlineDate.getUTCMonth(),
deadlineDate.getUTCDate()
)
const todayUTC = new Date(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate())

const isOverdue = deadlineUTC < todayUTC
const daysLeft = Math.ceil((deadlineUTC.getTime() - todayUTC.getTime()) / (1000 * 60 * 60 * 24))
const daysLeft = Math.ceil((deadlineUTC - todayUTC) / (1000 * 60 * 60 * 24))
const isOverdue = daysLeft < 0

let statusText: string
if (isOverdue) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,58 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { ErrorDisplay, handleAppError } from 'app/global-error'
import { GetModuleIssuesDocument } from 'types/__generated__/moduleQueries.generated'
import IssuesTable, { type IssueRow } from 'components/IssuesTable'
import IssuesTable from 'components/IssuesTable'
import LoadingSpinner from 'components/LoadingSpinner'
import Pagination from 'components/Pagination'

const ITEMS_PER_PAGE = 20
const LABEL_ALL = 'all'
const DEADLINE_ALL = 'all'
const DEADLINE_OPTIONS = [
{ key: 'all', label: 'All' },
{ key: 'overdue', label: 'Overdue' },
{ key: 'due-soon', label: 'Due Soon' },
{ key: 'upcoming', label: 'Upcoming' },
{ key: 'no-deadline', label: 'No Deadline' },
]

const getDeadlineCategory = (deadline?: string | null): string => {
if (!deadline) return 'no-deadline'

const now = new Date()
const deadlineDate = new Date(deadline)
const nowStartUtc = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
const deadlineStartUtc = Date.UTC(
deadlineDate.getUTCFullYear(),
deadlineDate.getUTCMonth(),
deadlineDate.getUTCDate()
)
const diffDays = Math.floor((deadlineStartUtc - nowStartUtc) / 86400000)

if (diffDays < 0) return 'overdue'
if (diffDays <= 7) return 'due-soon'
return 'upcoming'
}

const IssuesPage = () => {
const { programKey, moduleKey } = useParams<{ programKey: string; moduleKey: string }>()
const router = useRouter()
const searchParams = useSearchParams()
const [selectedLabel, setSelectedLabel] = useState<string>(searchParams.get('label') || LABEL_ALL)
const [selectedDeadline, setSelectedDeadline] = useState<string>(
searchParams.get('deadline') || DEADLINE_ALL
)
const [currentPage, setCurrentPage] = useState(1)

const isDeadlineFilterActive = selectedDeadline !== DEADLINE_ALL
const MAX_ISSUES_FOR_DEADLINE_FILTER = 1000

const { data, loading, error } = useQuery(GetModuleIssuesDocument, {
variables: {
programKey,
moduleKey,
limit: ITEMS_PER_PAGE,
offset: (currentPage - 1) * ITEMS_PER_PAGE,
limit: isDeadlineFilterActive ? MAX_ISSUES_FOR_DEADLINE_FILTER : ITEMS_PER_PAGE,
offset: isDeadlineFilterActive ? 0 : (currentPage - 1) * ITEMS_PER_PAGE,
label: selectedLabel === LABEL_ALL ? null : selectedLabel,
},
skip: !programKey || !moduleKey,
Expand All @@ -38,19 +70,35 @@ const IssuesPage = () => {

const moduleData = data?.getModule

const moduleIssues: IssueRow[] = useMemo(() => {
return (moduleData?.issues || []).map((i) => ({
const { moduleIssues, filteredCount } = useMemo(() => {
const allIssues = (moduleData?.issues || []).map((i) => ({
objectID: i.id,
number: i.number,
title: i.title,
state: i.state,
isMerged: i.isMerged,
labels: i.labels || [],
assignees: i.assignees || [],
deadline: i.taskDeadline ?? null,
}))
}, [moduleData])

const totalPages = Math.ceil((moduleData?.issuesCount || 0) / ITEMS_PER_PAGE)
if (selectedDeadline !== DEADLINE_ALL) {
// Filter by deadline category
const filtered = allIssues.filter(
(issue) => getDeadlineCategory(issue.deadline) === selectedDeadline
)
// Apply client-side pagination on filtered results
const start = (currentPage - 1) * ITEMS_PER_PAGE
const paginatedIssues = filtered.slice(start, start + ITEMS_PER_PAGE)
return { moduleIssues: paginatedIssues, filteredCount: filtered.length }
}

return { moduleIssues: allIssues, filteredCount: moduleData?.issuesCount || 0 }
}, [moduleData, selectedDeadline, currentPage])

const totalPages = Math.ceil(
(isDeadlineFilterActive ? filteredCount : moduleData?.issuesCount || 0) / ITEMS_PER_PAGE
)

const allLabels: string[] = useMemo(() => {
const serverLabels = moduleData?.availableLabels
Expand All @@ -77,6 +125,18 @@ const IssuesPage = () => {
router.replace(`?${params.toString()}`)
}

const handleDeadlineChange = (deadline: string) => {
setSelectedDeadline(deadline)
setCurrentPage(1)
const params = new URLSearchParams(searchParams.toString())
if (deadline === DEADLINE_ALL) {
params.delete('deadline')
} else {
params.set('deadline', deadline)
}
router.replace(`?${params.toString()}`)
}

const handlePageChange = (page: number) => {
setCurrentPage(page)
}
Expand All @@ -97,43 +157,76 @@ const IssuesPage = () => {
return (
<div className="mt-16 min-h-screen bg-white p-8 text-gray-600 dark:bg-[#212529] dark:text-gray-300">
<div className="mx-auto max-w-6xl">
<div className="mb-6 flex items-center justify-between">
<div className="mb-6 flex items-center justify-between gap-4">
<h1 className="text-3xl font-bold">{moduleData.name} Issues</h1>
<div className="inline-flex h-12 items-center rounded-lg bg-gray-200 dark:bg-[#323232]">
<Select
labelPlacement="outside-left"
size="md"
aria-label="Filter by label"
label="Label :"
classNames={{
label:
'font-small text-sm text-gray-600 hover:cursor-pointer dark:text-gray-300 pl-[1.4rem] w-auto',
trigger: 'bg-gray-200 dark:bg-[#323232] pl-0 text-nowrap w-40',
popoverContent: 'text-md min-w-40 dark:bg-[#323232] rounded-none p-0',
}}
selectedKeys={new Set([selectedLabel])}
onSelectionChange={(keys) => {
const [key] = Array.from(keys as Set<string>)
if (key) handleLabelChange(key)
}}
>
{[LABEL_ALL, ...allLabels].map((l) => (
<SelectItem
key={l}
classNames={{
base: 'text-sm hover:bg-[#D1DBE6] dark:hover:bg-[#454545] rounded-none px-3 py-0.5',
}}
>
{l === LABEL_ALL ? 'All' : l}
</SelectItem>
))}
</Select>
<div className="flex flex-col items-end gap-2 sm:flex-row sm:items-center sm:gap-3">
<div className="inline-flex h-12 items-center rounded-lg bg-gray-200 dark:bg-[#323232]">
<Select
labelPlacement="outside-left"
size="md"
aria-label="Filter by label"
label="Label :"
classNames={{
label:
'font-small text-sm text-gray-600 hover:cursor-pointer dark:text-gray-300 pl-[1.4rem] w-auto',
trigger: 'bg-gray-200 dark:bg-[#323232] pl-0 text-nowrap w-40',
popoverContent: 'text-md min-w-40 dark:bg-[#323232] rounded-none p-0',
}}
selectedKeys={new Set([selectedLabel])}
onSelectionChange={(keys) => {
const [key] = Array.from(keys as Set<string>)
if (key) handleLabelChange(key)
}}
>
{[LABEL_ALL, ...allLabels].map((l) => (
<SelectItem
key={l}
classNames={{
base: 'text-sm hover:bg-[#D1DBE6] dark:hover:bg-[#454545] rounded-none px-3 py-0.5',
}}
>
{l === LABEL_ALL ? 'All' : l}
</SelectItem>
))}
</Select>
</div>
<div className="inline-flex h-12 items-center rounded-lg bg-gray-200 dark:bg-[#323232]">
<Select
labelPlacement="outside-left"
size="md"
aria-label="Filter by deadline"
label="Deadline :"
classNames={{
label:
'font-small text-sm text-gray-600 hover:cursor-pointer dark:text-gray-300 pl-[1.4rem] w-auto',
trigger: 'bg-gray-200 dark:bg-[#323232] pl-0 text-nowrap w-36',
popoverContent: 'text-md min-w-36 dark:bg-[#323232] rounded-none p-0',
}}
selectedKeys={new Set([selectedDeadline])}
onSelectionChange={(keys) => {
const [key] = Array.from(keys as Set<string>)
if (key) handleDeadlineChange(key)
}}
>
{DEADLINE_OPTIONS.map((option) => (
<SelectItem
key={option.key}
classNames={{
base: 'text-sm hover:bg-[#D1DBE6] dark:hover:bg-[#454545] rounded-none px-3 py-0.5',
}}
>
{option.label}
</SelectItem>
))}
</Select>
</div>
</div>
</div>

<IssuesTable
issues={moduleIssues}
showAssignee={true}
showDeadline={true}
onIssueClick={handleIssueClick}
emptyMessage="No issues found for the selected filter."
/>
Expand Down
Loading