Skip to content
Merged
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
110 changes: 38 additions & 72 deletions src/hooks/useHomeDashboard.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { useCallback, useEffect, useState } from 'react'
import { deleteProject, getProjects, leaveProject, pinProject, unpinProject } from '../api/projects'
import { getDailySchedules, getTodayBriefing } from '../api/schedules'
import { useMemo } from 'react'
import {
useDeleteProjectMutation,
useLeaveProjectMutation,
useProjectsQuery,
useToggleProjectPinMutation,
} from '../queries/projects'
import { useTodayBriefingQuery, useTodaySchedulesQuery } from '../queries/home'
import { ApiError } from '../types/api'
import type { ProjectSummary } from '../types/project'
import type { ScheduleDailyItem, TodayBriefing } from '../types/schedule'
import { toDateKey } from '../utils/calendarUtils'

/** 홈 화면에 카드로 보여줄 진행 중인 프로젝트 개수 */
const HOME_PROJECT_LIMIT = 4
Expand All @@ -19,85 +22,48 @@ function sortByInProgressFirst(projects: ProjectSummary[]): ProjectSummary[] {
}

export function useHomeDashboard() {
const [projects, setProjects] = useState<ProjectSummary[]>([])
const [briefing, setBriefing] = useState<TodayBriefing | null>(null)
const [todaySchedules, setTodaySchedules] = useState<ScheduleDailyItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// 워크스페이스 목록과 같은 캐시(projectKeys.list())를 공유 — 홈↔워크스페이스 이동 시 재요청 없이 재사용된다
const projectsQuery = useProjectsQuery()
const briefingQuery = useTodayBriefingQuery()
const todaySchedulesQuery = useTodaySchedulesQuery()
const pinMutation = useToggleProjectPinMutation()
const deleteMutation = useDeleteProjectMutation()
const leaveMutation = useLeaveProjectMutation()

useEffect(() => {
let cancelled = false
const projects = useMemo(
() => sortByInProgressFirst(projectsQuery.projects).slice(0, HOME_PROJECT_LIMIT),
[projectsQuery.projects],
)

async function load() {
setLoading(true)
setError(null)
try {
const todayKey = toDateKey(new Date())
const [projectList, briefingResult, dailyResult] = await Promise.all([
getProjects(),
getTodayBriefing().catch(() => null),
getDailySchedules(todayKey).catch(() => ({
date: todayKey,
items: [] as ScheduleDailyItem[],
})),
])
if (cancelled) return

setProjects(sortByInProgressFirst(projectList.items).slice(0, HOME_PROJECT_LIMIT))
setBriefing(briefingResult)
setTodaySchedules(dailyResult.items)
} catch (err) {
if (!cancelled) {
setError(err instanceof ApiError ? err.message : '홈 정보를 불러오지 못했습니다.')
}
} finally {
if (!cancelled) setLoading(false)
}
}
const togglePin = (project: ProjectSummary) => {
pinMutation.mutate({ projectId: project.id, next: !project.isPinned })
}

void load()
return () => {
cancelled = true
}
}, [])
const removeProject = async (projectId: number) => {
await deleteMutation.mutateAsync(projectId)
}

const togglePin = useCallback(async (project: ProjectSummary) => {
const next = !project.isPinned
setProjects((prev) =>
prev.map((item) => (item.id === project.id ? { ...item, isPinned: next } : item)),
)
try {
const result = next ? await pinProject(project.id) : await unpinProject(project.id)
setProjects((prev) =>
prev.map((item) =>
item.id === project.id ? { ...item, isPinned: result.isPinned } : item,
),
)
} catch {
setProjects((prev) =>
prev.map((item) => (item.id === project.id ? { ...item, isPinned: !next } : item)),
)
}
}, [])
const leaveProject = async (projectId: number) => {
await leaveMutation.mutateAsync(projectId)
}

const removeProject = useCallback(async (projectId: number) => {
await deleteProject(projectId)
setProjects((prev) => prev.filter((project) => project.id !== projectId))
}, [])
const loading =
projectsQuery.isPending || briefingQuery.isPending || todaySchedulesQuery.isPending

const leaveCurrentProject = useCallback(async (projectId: number) => {
await leaveProject(projectId)
setProjects((prev) => prev.filter((project) => project.id !== projectId))
}, [])
const error = projectsQuery.isError
? projectsQuery.error instanceof ApiError
? projectsQuery.error.message
: '홈 정보를 불러오지 못했습니다.'
: null

return {
projects,
briefing,
todaySchedules,
briefing: briefingQuery.data ?? null,
todaySchedules: todaySchedulesQuery.data?.items ?? [],
loading,
error,
togglePin,
removeProject,
leaveProject: leaveCurrentProject,
leaveProject,
}
}
68 changes: 22 additions & 46 deletions src/hooks/useNotifications.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,34 @@
import { useEffect, useState } from 'react'
import { getNotifications, readAllNotifications, readNotification } from '../api/notifications'
import {
useMarkAllNotificationsReadMutation,
useMarkNotificationReadMutation,
useNotificationsQuery,
} from '../queries/notifications'
import { ApiError } from '../types/api'
import type { AppNotification } from '../types/notification'

export function useNotifications() {
const [notifications, setNotifications] = useState<AppNotification[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const notificationsQuery = useNotificationsQuery()
const markReadMutation = useMarkNotificationReadMutation()
const markAllReadMutation = useMarkAllNotificationsReadMutation()

useEffect(() => {
let cancelled = false

async function load() {
setLoading(true)
setError(null)
try {
const result = await getNotifications()
if (!cancelled) setNotifications(result.items)
} catch (err) {
if (!cancelled) {
setError(err instanceof ApiError ? err.message : '알림을 불러오지 못했습니다.')
}
} finally {
if (!cancelled) setLoading(false)
}
}

void load()
return () => {
cancelled = true
}
}, [])
const error = notificationsQuery.isError
? notificationsQuery.error instanceof ApiError
? notificationsQuery.error.message
: '알림을 불러오지 못했습니다.'
: null

async function markAsRead(notificationId: number) {
setNotifications((prev) =>
prev.map((item) =>
item.notificationId === notificationId ? { ...item, isRead: true } : item,
),
)
try {
await readNotification(notificationId)
} catch {
// 낙관적 업데이트 유지 — 재조회 시 서버 상태로 정정됨
}
await markReadMutation.mutateAsync(notificationId)
}

async function markAllAsRead() {
setNotifications((prev) => prev.map((item) => ({ ...item, isRead: true })))
try {
await readAllNotifications()
} catch {
// 낙관적 업데이트 유지 — 재조회 시 서버 상태로 정정됨
}
await markAllReadMutation.mutateAsync()
}

return { notifications, loading, error, markAsRead, markAllAsRead }
return {
notifications: notificationsQuery.data ?? [],
loading: notificationsQuery.isPending,
error,
markAsRead,
markAllAsRead,
}
}
21 changes: 21 additions & 0 deletions src/queries/home.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useQuery } from '@tanstack/react-query'
import { getDailySchedules, getTodayBriefing } from '../api/schedules'
import { toDateKey } from '../utils/calendarUtils'
import { homeKeys } from './keys'

export function useTodayBriefingQuery() {
const dateKey = toDateKey(new Date())
return useQuery({
queryKey: homeKeys.briefing(dateKey),
// 브리핑이 없거나 실패해도 홈 화면 전체를 에러로 내리지 않고 빈 상태로 취급
queryFn: () => getTodayBriefing().catch(() => null),
})
}

export function useTodaySchedulesQuery() {
const dateKey = toDateKey(new Date())
return useQuery({
queryKey: homeKeys.todaySchedules(dateKey),
queryFn: () => getDailySchedules(dateKey).catch(() => ({ date: dateKey, items: [] })),
})
}
12 changes: 12 additions & 0 deletions src/queries/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,15 @@ export const projectKeys = {
notices: (projectId: number) => [...projectKeys.detail(projectId), 'notices'] as const,
activities: (projectId: number) => [...projectKeys.detail(projectId), 'activities'] as const,
}

/** 홈 대시보드 도메인 query key (오늘 브리핑·오늘 일정) */
export const homeKeys = {
briefing: (date: string) => ['home', 'briefing', date] as const,
todaySchedules: (date: string) => ['home', 'todaySchedules', date] as const,
}

/** 알림 도메인 query key */
export const notificationKeys = {
all: ['notifications'] as const,
list: () => [...notificationKeys.all, 'list'] as const,
}
57 changes: 57 additions & 0 deletions src/queries/notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { getNotifications, readAllNotifications, readNotification } from '../api/notifications'
import type { AppNotification } from '../types/notification'
import { notificationKeys } from './keys'

export function useNotificationsQuery() {
return useQuery({
queryKey: notificationKeys.list(),
queryFn: async () => {
const result = await getNotifications()
return result.items
},
})
}

function markRead(
data: AppNotification[] | undefined,
notificationId?: number,
): AppNotification[] | undefined {
if (!data) return data
return data.map((item) =>
notificationId == null || item.notificationId === notificationId
? { ...item, isRead: true }
: item,
)
}

export function useMarkNotificationReadMutation() {
const queryClient = useQueryClient()

return useMutation({
mutationFn: (notificationId: number) => readNotification(notificationId),
onMutate: async (notificationId) => {
await queryClient.cancelQueries({ queryKey: notificationKeys.list() })
const previous = queryClient.getQueryData<AppNotification[]>(notificationKeys.list())
queryClient.setQueryData<AppNotification[]>(notificationKeys.list(), (prev) =>
markRead(prev, notificationId),
)
return { previous }
},
// 낙관적 업데이트 유지 — 실패해도 롤백하지 않고 다음 재조회 시 서버 상태로 정정됨
})
}

export function useMarkAllNotificationsReadMutation() {
const queryClient = useQueryClient()

return useMutation({
mutationFn: () => readAllNotifications(),
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: notificationKeys.list() })
const previous = queryClient.getQueryData<AppNotification[]>(notificationKeys.list())
queryClient.setQueryData<AppNotification[]>(notificationKeys.list(), (prev) => markRead(prev))
return { previous }
},
})
}
Loading