diff --git a/src/components/EditionList.tsx b/src/components/EditionList.tsx index 528ff02..fda1b94 100644 --- a/src/components/EditionList.tsx +++ b/src/components/EditionList.tsx @@ -47,9 +47,9 @@ export function EditionList() { 참여하기 )} - {result && ( + {(result || (e.survey && e.resultsAt)) && ( - 결과 보기 + {result ? '결과 보기' : '결과 안내'} )} diff --git a/src/components/UpcomingResults.tsx b/src/components/UpcomingResults.tsx new file mode 100644 index 0000000..4db8754 --- /dev/null +++ b/src/components/UpcomingResults.tsx @@ -0,0 +1,150 @@ +import { Link } from '@tanstack/react-router' +import { Heading, Icon, Tag, Text } from 'daleui' +import { SiteFooter } from '@/components/SiteFooter' +import { SiteHeader } from '@/components/SiteHeader' +import { type Edition, isClosed } from '@/editions' +import { koDate } from '@/lib/date' +import { sectionIcon } from '@/questions/sectionIcons' +import { questionsFor } from '@/questions/survey' +import { BREAKDOWNS, MIN_GROUP } from '@/results/aggregate' + +const cta: React.CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + minHeight: 52, + padding: '0 var(--spacing-24)', + borderRadius: 'var(--radii-md)', + background: 'var(--colors-bg-solid-brand)', + color: 'var(--colors-fg-solid-brand)', + fontSize: '1.125rem', + fontWeight: 500, + textDecoration: 'none', +} + +// 결과 공개 전의 결과 화면. 언제 무엇이 공개되는지와 응답을 어떻게 분석하는지 알린다. +// 집계 규칙(MIN_GROUP, BREAKDOWNS)을 그대로 읽어서 실제 결과 화면과 설명이 어긋나지 않는다. +export function UpcomingResults({ edition: e }: { edition: Edition & { resultsAt: string } }) { + const open = !isClosed(e) + const sections = [ + ...(questionsFor(e.id) ?? []) + .reduce((m, q) => m.set(q.sectionId, { title: q.section, count: (m.get(q.sectionId)?.count ?? 0) + 1 }), new Map()) + .entries(), + ] + const method = [ + { + icon: 'chartBar', + title: '문항마다 비율로 정리해요', + body: `${e.closesAt ? `${koDate(e.closesAt)} 마감 뒤 ` : ''}응답을 모아 선택지별 비율과 척도 평균을 계산해요. 공개되는 건 이 집계뿐이고, 원본 응답은 공개하지 않아요.`, + }, + { + icon: 'users', + title: '집단별로 나눠 봐요', + body: `${BREAKDOWNS.map((b) => `${b.label}(${b.groups.map((g) => g.label).join('·')})`).join(', ')}로 나눠 같은 문항의 답을 비교할 수 있어요.`, + }, + { + icon: 'eyeOff', + title: `${MIN_GROUP}명보다 적으면 숨겨요`, + body: '답한 사람이 적은 문항이나 집단은 누가 답했는지 짐작할 수 있어서 결과에서 빼요.', + }, + { + icon: 'messageCircle', + title: '자유 의견은 따로 정리해요', + body: '차트로 보여 주지 않아요. 이름이나 회사명처럼 특정인을 알아볼 수 있는 정보를 먼저 지워요.', + }, + ] as const + + return ( +
+ +
+
+
+ {koDate(e.resultsAt)} 공개 + {e.title} 결과 + + {open + ? `지금 응답을 모으고 있어요. 마감 뒤 분석을 거쳐 ${koDate(e.resultsAt)}에 누구나 볼 수 있게 공개해요.` + : `응답을 마감하고 분석하고 있어요. ${koDate(e.resultsAt)}에 누구나 볼 수 있게 공개해요.`} + + {open && ( + + 설문 참여하기 + + )} +
+ +
+ + 어떻게 분석하나요 + +
    + {method.map((m) => ( +
  • + + {m.title} + + {m.body} + +
  • + ))} +
+
+ + {sections.length > 0 && ( +
+
+ + 무엇을 공개하나요 + + 설문에서 물은 주제를 모두 공개해요. +
+
    + {sections.map(([id, s]) => ( +
  • + +
    + {s.title} + + {s.count}문항 + +
    +
  • + ))} +
+
+ )} + +
+ + 읽을 때 알아 두세요 + + + 스스로 참여한 분들의 답이라서 한국 개발자 전체를 그대로 대표하지는 않아요. 결과 화면에는 문항마다 몇 명이 답했는지 함께 적어요. + +
+
+
+ +
+ ) +} diff --git a/src/routes/$edition.index.tsx b/src/routes/$edition.index.tsx index 2444f21..e64a436 100644 --- a/src/routes/$edition.index.tsx +++ b/src/routes/$edition.index.tsx @@ -154,6 +154,11 @@ function EditionPage() { ))} + {!result && e.resultsAt && ( + + 결과를 어떻게 분석하나요 + + )} )} diff --git a/src/routes/$edition.results.tsx b/src/routes/$edition.results.tsx index 7534411..17276c5 100644 --- a/src/routes/$edition.results.tsx +++ b/src/routes/$edition.results.tsx @@ -4,24 +4,33 @@ import { findEdition } from '@/editions' import { SiteFooter } from '@/components/SiteFooter' import { SiteHeader } from '@/components/SiteHeader' import { ResultChart } from '@/components/ResultChart' +import { UpcomingResults } from '@/components/UpcomingResults' import { sectionIcon } from '@/questions/sectionIcons' import { RESULTS } from '@/results' -import { type Breakdown, MIN_GROUP, type QuestionStat } from '@/results/aggregate' +import { type Breakdown, MIN_GROUP, type QuestionStat, type ResultSet } from '@/results/aggregate' import { displayOptions } from '@/results/order' // 화면은 집계(src/results/<회차>.json)만 읽는다. 원본 응답은 여기로 오지 않는다. +// 집계가 아직 없으면 공개일과 분석 방법을 알리는 예고 화면을 보여 준다. export const Route = createFileRoute('/$edition/results')({ validateSearch: (search: Record): { by?: string } => (typeof search.by === 'string' ? { by: search.by } : {}), loader: ({ params }) => { const edition = findEdition(params.edition) + if (!edition) throw notFound() const result = RESULTS[params.edition] - if (!edition || !result) throw notFound() - return { title: edition.title, result } + if (result) return { kind: 'result' as const, title: edition.title, result } + if (!edition.survey || !edition.resultsAt) throw notFound() + return { kind: 'upcoming' as const, title: edition.title, edition: { ...edition, resultsAt: edition.resultsAt } } }, head: ({ loaderData }) => ({ meta: [{ title: loaderData ? `${loaderData.title} 결과` : '결과' }] }), - component: Results, + component: ResultsPage, }) +function ResultsPage() { + const data = Route.useLoaderData() + return data.kind === 'result' ? : +} + const percent = (share: number) => `${Math.round(share * 100)}%` function QuestionResult({ q, breakdown }: { q: QuestionStat; breakdown?: Breakdown }) { @@ -140,8 +149,7 @@ function BreakdownPicker({ breakdowns, active }: { breakdowns: Breakdown[]; acti ) } -function Results() { - const { title, result } = Route.useLoaderData() +function Results({ title, result }: { title: string; result: ResultSet }) { const { by } = Route.useSearch() const breakdown = result.breakdowns.find((b) => b.id === by) const sections = [...new Map(result.questions.map((q) => [q.sectionId, q.section]))]