Skip to content

Commit

Permalink
feat: define stats atom family, subsribe to stats
Browse files Browse the repository at this point in the history
  • Loading branch information
sripwoud committed Nov 4, 2024
1 parent 9a33a02 commit f28b454
Show file tree
Hide file tree
Showing 16 changed files with 167 additions and 65 deletions.
5 changes: 5 additions & 0 deletions .biome.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"apps/client/src/hooks/useAuth.ts",
"apps/client/src/hooks/useIdentity.ts",
"apps/client/src/hooks/useSubscribeQuestions.ts",
"apps/client/src/hooks/useSubscribeStats.ts",
"apps/client/src/hooks/useSendFeedback.ts",
"apps/client/src/components/withAuth.tsx",
"apps/client/src/app/[groupId]/[questionId]/page.tsx",
Expand All @@ -82,5 +83,9 @@
],
"linter": { "rules": { "nursery": { "useComponentExportOnlyModules": "off" } } },
},
{
"include": ["apps/client/src/state/questions/atom.ts"],
"linter": { "rules": { "style": { "noNonNullAssertion": "off" } } },
},
],
}
17 changes: 17 additions & 0 deletions apps/client/src/app/[groupId]/[questionId]/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
'use client'
import { useSubscribeStats } from 'client/h/useSubscribeStats'
import type { ReactNode } from 'react'

export default function QuestionLayout(
{ children, params: { groupId, questionId } }: {
children: ReactNode
params: { groupId: string; questionId: string }
},
) {
useSubscribeStats({ groupId, questionId: Number.parseInt(questionId) })
return (
<>
{children}
</>
)
}
25 changes: 11 additions & 14 deletions apps/client/src/app/[groupId]/[questionId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,27 @@
import { useUser } from '@account-kit/react'
import { Loader } from 'client/c/Loader'
import { withAuth } from 'client/components/withAuth'
// import { useQuestionStats } from 'client/h/useQuestionStats'
import { trpc } from 'client/l/trpc'
import { useEffect } from 'react'
import { questionAtom } from 'client/state/questions/atom'
import { statsByQuestionAtom } from 'client/state/stats/atom'
import { useAtomValue } from 'jotai'

const QuestionDetails = ({ params: { questionId: questionIdStr } }: { params: { questionId: string } }) => {
const QuestionDetails = (
{ params: { groupId, questionId: questionIdStr } }: { params: { groupId: string; questionId: string } },
) => {
const questionId = Number.parseInt(questionIdStr)
const user = useUser()
const { mutate: toggle, isPending } = trpc.questions.toggle.useMutation()
const { data: question, isLoading, refetch } = trpc.questions.find.useQuery({ questionId }, {
select: ({ data }) => data,
})
// TODO
// const { data: { no, yes } } = useQuestionStats({ questionId })
const { no, yes } = useAtomValue(statsByQuestionAtom)(questionId)
const question = useAtomValue(questionAtom)({ groupId, questionId })

useEffect(() => {
refetch()
}, [isPending])
if (isPending) return <Loader />

if (isLoading || question === undefined || question === null) return <Loader />
return (
<div>
<h1 className='text-2xl'>{question.title}</h1>
<p>yes: TODO</p>
<p>no: TODO</p>
<p>yes: {yes}</p>
<p>no: {no}</p>
{user?.email === question.author && (
<button
type='button'
Expand Down
33 changes: 0 additions & 33 deletions apps/client/src/hooks/useQuestionStats.ts

This file was deleted.

26 changes: 26 additions & 0 deletions apps/client/src/hooks/useSubscribeStats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { trpc } from 'client/l/trpc'
import { questionTypeAtom } from 'client/state/questions/atom'
import * as actions from 'client/state/stats/actions'
import { statsAtomFamily } from 'client/state/stats/atom'
import { useAtomValue, useSetAtom } from 'jotai'
import { useEffect } from 'react'

export const useSubscribeStats = ({ groupId, questionId }: { groupId: string; questionId: number }) => {
const dispatch = useSetAtom(statsAtomFamily(questionId))
const questionType = useAtomValue(questionTypeAtom)({ groupId, questionId })
const { data: stats } = trpc.questions.stats.useQuery({ questionId, type: questionType })

useEffect(() => {
if (stats === undefined) return
dispatch(actions.init(stats))
}, [stats])

trpc.feedbacks.onInsert.useSubscription({ questionId }, {
onData: ({ data: { feedback, question_id } }) => {
dispatch(actions.update({ feedback, question_id, type: questionType }))
},
onError: (error) => {
console.error(error)
},
})
}
6 changes: 3 additions & 3 deletions apps/client/src/state/questions/actions.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { type Questions, type QuestionsAction, QuestionsActionType } from 'client/state/questions/types'
import type { Question } from 'server/questions/entities'
import { type Questions, type QuestionsAction, QuestionsActionType } from './types'

export const init = (payload: Questions): QuestionsAction => ({
type: QuestionsActionType.FIND_ALL,
type: QuestionsActionType.INIT,
payload,
})

export const update = (payload: Question): QuestionsAction => ({
type: QuestionsActionType.ON_CHANGE,
type: QuestionsActionType.UPDATE,
payload,
})
14 changes: 11 additions & 3 deletions apps/client/src/state/questions/atom.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import { initialQuestionsState, questionsReducer } from 'client/state/questions/reducer'
import type { Questions, QuestionsAction } from 'client/state/questions/types'
import deepEqual from 'fast-deep-equal'
import { atom, type WritableAtom } from 'jotai'
import { atomFamily, atomWithReducer } from 'jotai/utils'
import { initialQuestionsState, questionsReducer } from './reducer'
import type { Questions, QuestionsAction } from './types'

export const questionsAtomFamily = atomFamily<string, WritableAtom<Questions, [QuestionsAction], void>>(
(_groupId: string) => atomWithReducer<Questions, QuestionsAction>(initialQuestionsState, questionsReducer),
(_groupId) => atomWithReducer<Questions, QuestionsAction>(initialQuestionsState, questionsReducer),
deepEqual,
)

export const questionsByGroupAtom = atom((get) => (groupId: string) => Object.values(get(questionsAtomFamily(groupId))))

export const questionTypeAtom = atom((get) => ({ groupId, questionId }: { groupId: string; questionId: number }) =>
get(questionsAtomFamily(groupId))[questionId]!.type
)

export const questionAtom = atom(get => ({ groupId, questionId }: { groupId: string; questionId: number }) =>
get(questionsAtomFamily(groupId))[questionId]!
)
6 changes: 3 additions & 3 deletions apps/client/src/state/questions/reducer.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type { Questions, QuestionsAction } from './types'
import type { Questions, QuestionsAction } from 'client/state/questions/types'

export const initialQuestionsState: Questions = {}

export const questionsReducer = (state: Questions, { type, payload }: QuestionsAction): Questions => {
switch (type) {
case 'FIND_ALL':
case 'INIT':
return payload
case 'ON_CHANGE':
case 'UPDATE':
return { ...state, [payload.id]: payload }
default:
return state
Expand Down
8 changes: 4 additions & 4 deletions apps/client/src/state/questions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ import type { Router } from 'server/trpc/trpc.router'
export type Questions = inferRouterOutputs<Router>['questions']['findAll']

export enum QuestionsActionType {
FIND_ALL = 'FIND_ALL',
ON_CHANGE = 'ON_CHANGE',
INIT = 'INIT',
UPDATE = 'UPDATE',
}

export type QuestionsAction = {
type: QuestionsActionType.FIND_ALL
type: QuestionsActionType.INIT
payload: Questions
} | {
type: QuestionsActionType.ON_CHANGE
type: QuestionsActionType.UPDATE
payload: Question
}
11 changes: 11 additions & 0 deletions apps/client/src/state/stats/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { type Stats, type StatsAction, StatsActionType, type StatsUpdatePayload } from 'client/state/stats/types'

export const init = (payload: Stats): StatsAction => ({
type: StatsActionType.INIT,
payload,
})

export const update = (payload: StatsUpdatePayload): StatsAction => ({
type: StatsActionType.UPDATE,
payload,
})
12 changes: 12 additions & 0 deletions apps/client/src/state/stats/atom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { initialStatsState, statsReducer } from 'client/state/stats/reducer'
import type { Stats, StatsAction } from 'client/state/stats/types'
import deepEqual from 'fast-deep-equal'
import { atom, type WritableAtom } from 'jotai'
import { atomFamily, atomWithReducer } from 'jotai/utils'

export const statsAtomFamily = atomFamily<number, WritableAtom<Stats, [StatsAction], void>>(
(_questionId) => atomWithReducer<Stats, StatsAction>(initialStatsState, statsReducer),
deepEqual,
)

export const statsByQuestionAtom = atom((get) => (questionId: number) => get(statsAtomFamily(questionId)))
26 changes: 26 additions & 0 deletions apps/client/src/state/stats/reducer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { type Stats, type StatsAction, StatsActionType } from 'client/state/stats/types'

export const initialStatsState: Stats = { no: 0, yes: 0 }

export const statsReducer = (state: Stats, { type, payload }: StatsAction): Stats => {
switch (type) {
case StatsActionType.INIT:
return payload
case StatsActionType.UPDATE:
switch (payload.type) {
case 'boolean':
switch (payload.feedback) {
case 'yes':
return { ...state, yes: state.yes + 1 }
case 'no':
return { ...state, no: state.no + 1 }
default:
throw new Error('feedback value incompatible with question type')
}
default:
throw new Error('Unsupported question type')
}
default:
return state
}
}
21 changes: 21 additions & 0 deletions apps/client/src/state/stats/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { inferRouterOutputs } from '@trpc/server'
import type { Feedback } from 'server/feedbacks/entities'
import type { Question } from 'server/questions/entities'
import type { Router } from 'server/trpc/trpc.router'

export type Stats = inferRouterOutputs<Router>['questions']['stats']

export enum StatsActionType {
INIT = 'INIT',
UPDATE = 'UPDATE',
}

export type StatsUpdatePayload = Pick<Feedback, 'question_id' | 'feedback'> & { type: Question['type'] }

export type StatsAction = {
type: StatsActionType.INIT
payload: Stats
} | {
type: StatsActionType.UPDATE
payload: StatsUpdatePayload
}
7 changes: 7 additions & 0 deletions apps/server/src/feedbacks/dto/subscribe-to-feedbacks.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { z } from 'zod'

export const SubscribeToFeedbacksDto = z.object({
questionId: z.number().int().positive(),
})

export type SubscribeToFeedbacksDto = z.infer<typeof SubscribeToFeedbacksDto>
13 changes: 9 additions & 4 deletions apps/server/src/feedbacks/feedbacks.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { CreateFeedbackDto, SendFeedbackDto } from 'server/feedbacks/dto'
import { Feedback } from 'server/feedbacks/entities'
import { FeedbacksService } from 'server/feedbacks/feedbacks.service'
import { TrpcService } from 'server/trpc/trpc.service'
import { SubscribeToFeedbacksDto } from './dto/subscribe-to-feedbacks.dto'

@Injectable()
export class FeedbacksRouter {
Expand All @@ -15,9 +16,13 @@ export class FeedbacksRouter {
router = this.trpc.router({
create: this.trpc.procedure.input(CreateFeedbackDto).query(async ({ input }) => this.feedbacks.create(input)),
send: this.trpc.procedure.input(SendFeedbackDto).mutation(async ({ input }) => this.feedbacks.send(input)),
onInsert: this.trpc.procedure.subscription(async function*({ ctx: { events }, signal }) {
for await (const [payload] of on(events, 'feedbacks.change', { signal }))
yield payload as { type: 'INSERT'; data: Feedback }
}),
onInsert: this.trpc.procedure.input(SubscribeToFeedbacksDto).subscription(
async function*({ ctx: { events }, input: { questionId }, signal }) {
for await (const [payload] of on(events, 'feedbacks.change', { signal })) {
const typedPayload = payload as { type: 'INSERT'; data: Feedback }
if (typedPayload.data.question_id === questionId) yield payload as { type: 'INSERT'; data: Feedback }
}
},
),
})
}
2 changes: 1 addition & 1 deletion apps/server/src/questions/questions.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export class QuestionsService implements OnModuleInit {
'created_at',
{ ascending: false },
)
return data?.reduce<Record<string, Question>>((acc, question) => {
return data?.reduce<Record<number, Question>>((acc, question) => {
acc[question.id] = question
return acc
}, {}) ?? {}
Expand Down

0 comments on commit f28b454

Please sign in to comment.