diff --git a/AGENTS.md b/AGENTS.md index 7ac56aa..9cf32bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ bun scripts/og-images.ts <임시 폴더> # 유형 공유 페이지 og:image **TanStack Start** on **Cloudflare Workers**, D1 + Drizzle, 달레 UI(`docs/stack.md`). -- 화면은 `src/routes/` 의 파일 기반 라우트다. 설문을 여는 버튼("설문 시작하기")은 회차 페이지(`/<회차>`)에만 두고, 홈과 유형 공유 페이지의 참여 버튼은 회차 페이지로 보낸다. 설문 진행 화면을 뺀 모든 화면에 `SiteHeader`·`SiteFooter` 를 둔다(헤더의 "결과" 메뉴는 예시가 아닌 결과가 생기면 저절로 나온다). 응답 완료·이미 응답·마감 화면은 `$edition.survey.tsx` 의 `Finished` 하나로 그린다. +- 화면은 `src/routes/` 의 파일 기반 라우트다. 설문을 여는 버튼("설문 시작하기")은 회차 페이지(`/<회차>`)에만 두고, 홈, 유형 공유 페이지, 헤더의 참여 버튼은 회차 페이지로 보낸다. 설문 진행 화면을 뺀 모든 화면에 `SiteHeader`·`SiteFooter` 를 둔다(헤더의 "결과" 메뉴는 예시가 아닌 결과가 생기면, "참여하기"는 응답을 받는 회차가 있으면 저절로 나온다). 응답 완료·이미 응답·마감 화면은 `$edition.survey.tsx` 의 `Finished` 하나로 그린다. - 마감은 회차의 `closesAt` 으로 정한다. 화면과 서버 함수가 함께 본다. 회차의 `opensAt` 전(`isBeta`)에는 종료 화면에 "다시 답하기"가 나온다. - 익명 설문이다. 로그인이 없고, 응답자를 알아볼 수 있는 값은 스키마에 칸을 두지 않는 것으로 지킨다. 개발자 유형도 브라우저에서만 계산한다. 서버와 브라우저에 무엇을 두는지는 `docs/data-model.md`. - 결과 화면은 `src/results/<회차>.json` 만 읽고 원본 응답을 모른다(집계 규칙은 `docs/data-model.md`). TanStack Charts(`src/components/ResultChart.tsx`)로 그리고, 보기 기준은 `?by=` 에 남는다. 차트 색은 dataviz 검증 스크립트를 통과한 값이다. diff --git a/docs/stack.md b/docs/stack.md index dc6b9fd..84741c6 100644 --- a/docs/stack.md +++ b/docs/stack.md @@ -25,7 +25,7 @@ Tally 같은 설문 도구 대신 직접 만든다. 응답 화면을 달레 UI - `worker-configuration.d.ts` — `bun run cf-typegen`. 저장소를 새로 받으면 `bun run test` 전에 한 번 돌린다(`tsconfig.json` 이 이 파일을 읽는다). 시크릿 타입은 `wrangler types` 가 모르므로 `src/env.d.ts` 에 더한다. - **Worker 바인딩**은 서버 코드에서 `import { env } from 'cloudflare:workers'` 로 읽는다. - **타입 검사 범위:** `tsconfig.json` 이 `*.test.ts` 와 `scripts/` 를 뺀다. `bun test` 는 타입을 보지 않으므로, 테스트와 스크립트의 타입 오류는 실행해야 드러난다. -- **스타일:** 달레 UI 컴포넌트를 먼저 쓰고, 배치는 인라인 `style` 에 달레 UI CSS 변수(`var(--colors-…)`, `var(--spacing-…)`)로 한다. 별도 CSS 프레임워크는 없다. +- **스타일:** 달레 UI 컴포넌트를 먼저 쓰고, 배치는 인라인 `style` 에 달레 UI CSS 변수(`var(--colors-…)`, `var(--spacing-…)`)로 한다. 별도 CSS 프레임워크는 없다. 여러 화면이 같이 쓰는 제목 단계, 첫 화면 액자, 주 행동 링크, 브랜드 그라데이션은 `src/lib/brand.ts` 에 둔다. 인라인으로 할 수 없는 `:hover`, `:focus-visible`, 화면 너비별 규칙만 컴포넌트 안의 ` +
@@ -28,11 +45,11 @@ export function QuestionStep({ question: q, value, error, onPick, onToggle, onTe {!q.required && 건너뛰어도 돼요}
- + {q.label} {q.help && ( - + {q.help} )} @@ -44,26 +61,31 @@ export function QuestionStep({ question: q, value, error, onPick, onToggle, onTe aria-labelledby="question" style={{ display: 'grid', - gridTemplateColumns: q.options.length >= 9 ? 'repeat(auto-fill, minmax(220px, 1fr))' : '1fr', - gap: 'var(--spacing-8)', - maxWidth: q.options.length >= 9 ? 640 : 440, + gridTemplateColumns: many ? 'repeat(auto-fill, minmax(min(100%, 220px), 1fr))' : '1fr', + gap: 10, }} > - {q.options.map((o) => { + {q.options.map((o, i) => { const on = q.type === 'multi' ? picked.includes(o.code) : value === o.code return ( - +
@@ -82,12 +104,6 @@ export function QuestionStep({ question: q, value, error, onPick, onToggle, onTe )} - {q.type === 'multi' && ( - - {picked.length}개 골랐어요. {q.limit ? `최대 ${q.limit}개까지 고를 수 있어요.` : '여러 개 골라도 돼요.'} - - )} - {q.type === 'scale' && q.scale && (
`${Math.round(share * 100)}%` @@ -37,6 +43,21 @@ function withHeadroom(rows: Row[]) { return scaleLinear().domain([0, Math.max(0.01, ...rows.map((r) => r.share)) * 1.18]) } +// 쌓은 막대 칸의 가운데. 막대는 3점 칸의 가운데를 0에 두고 양쪽으로 쌓는다(stack 의 anchor 와 같은 기준). +function likertLabels(rows: Row[]) { + const byLabel = new Map() + for (const r of rows) byLabel.set(r.label, [...(byLabel.get(r.label) ?? []), r]) + return [...byLabel.values()].flatMap((segments) => { + const shares = segments.map((r) => r.share) + let start = -(shares[0] + shares[1] + shares[2] / 2) + return segments.flatMap((r, i) => { + const at = start + r.share / 2 + start += r.share + return r.share >= LIKERT_LABEL_MIN ? [{ ...r, at, fill: LIKERT_TEXT[i] }] : [] + }) + }) +} + function likertRows(label: string, shares: Record, scale?: { min: string; max: string }): Row[] { return SCALE_POINTS.map((code) => ({ label, @@ -73,6 +94,16 @@ export function ResultChart({ q, breakdown }: { q: QuestionStat; breakdown?: Bre inset: 1, layout: stack({ order: likertSeries, anchor: { series: likertSeries[2], fraction: 0.5 } }), }), + text(likertLabels(rows), { + x: 'at', + y: 'label', + key: (d) => `${d.label}:${d.series}`, + text: (d) => percent(d.share), + anchor: 'middle', + fill: (d) => d.fill, + fontSize: 12, + fontWeight: 700, + }), ], scales: { x: { scale: scaleLinear, nice: true, axis: { ticks: { format: (v: number) => percent(Math.abs(v)) } }, grid: true }, diff --git a/src/components/SiteHeader.tsx b/src/components/SiteHeader.tsx index 62761b8..0c06086 100644 --- a/src/components/SiteHeader.tsx +++ b/src/components/SiteHeader.tsx @@ -1,6 +1,6 @@ -import { Link } from '@tanstack/react-router' +import { Link, useLocation } from '@tanstack/react-router' import { Icon, Text } from 'daleui' -import { EDITIONS } from '@/editions' +import { EDITIONS, openEditions } from '@/editions' import { RESULTS } from '@/results' import { Logo } from '@/components/Logo' @@ -8,6 +8,10 @@ import { Logo } from '@/components/Logo' export function SiteHeader() { // 예시가 아닌 결과가 생기기 전에는 결과 메뉴가 가리킬 곳이 없다 const hasResults = EDITIONS.some((e) => !e.sample && RESULTS[e.id]) + // 응답을 받는 회차가 있으면 참여 버튼을 둔다. 설문은 회차 페이지에서 열므로 그리로 보내고, 회차 페이지에서는 숨긴다. + const open = openEditions()[0] + const { pathname } = useLocation() + const showJoin = open && pathname !== `/${open.id}` return (
{/* 좁은 화면에서는 로고 마크가 달레 스터디를 대신한다 */} - + {/* 참여 버튼이 있으면 소셜 아이콘도 숨긴다. 같은 링크가 하단(SiteFooter)에 있다 */} + 달레 스터디 개발자 설문조사 @@ -37,6 +42,7 @@ export function SiteHeader() { href="https://linkedin.com/company/dalestudy" target="_blank" rel="noopener noreferrer" + className="site-social" aria-label="달레 스터디 링크드인" style={{ display: 'inline-flex', padding: 'var(--spacing-8)', margin: 'calc(var(--spacing-8) * -1)', color: 'inherit' }} > @@ -46,6 +52,7 @@ export function SiteHeader() { href="https://dales.link/discord" target="_blank" rel="noopener noreferrer" + className="site-social" aria-label="달레 스터디 디스코드" style={{ display: 'inline-flex', padding: 'var(--spacing-8)', margin: 'calc(var(--spacing-8) * -1)', color: 'inherit' }} > @@ -55,11 +62,34 @@ export function SiteHeader() { href="https://github.com/DaleStudy/survey" target="_blank" rel="noopener noreferrer" + className="site-social" aria-label="GitHub 저장소" style={{ display: 'inline-flex', padding: 'var(--spacing-8)', margin: 'calc(var(--spacing-8) * -1)', color: 'inherit' }} > + {showJoin && ( + + 참여하기 + + )}
diff --git a/src/components/UpcomingResults.tsx b/src/components/UpcomingResults.tsx index c71c81e..94bb450 100644 --- a/src/components/UpcomingResults.tsx +++ b/src/components/UpcomingResults.tsx @@ -3,23 +3,15 @@ import { Heading, Icon, Tag, Text } from 'daleui' import { SiteFooter } from '@/components/SiteFooter' import { SiteHeader } from '@/components/SiteHeader' import { type Edition, isClosed } from '@/editions' +import { ctaLink } from '@/lib/brand' import { koDate } from '@/lib/date' import { sectionIcon } from '@/questions/sectionIcons' import { questionsFor } from '@/questions/survey' import { BREAKDOWNS } 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', -} +const wrap: React.CSSProperties = { maxWidth: 1120, margin: '0 auto', padding: '0 24px', boxSizing: 'border-box' } +const sectionHeading: React.CSSProperties = { fontSize: 'clamp(1.625rem, 1.5vw + 1rem, 1.875rem)', fontWeight: 800, letterSpacing: '-0.025em' } +const brandRule = '1px solid var(--colors-bg-brand-active)' // 결과 공개 전의 결과 화면. 언제 무엇이 공개되는지와 응답을 어떻게 분석하는지 알린다. // 집계 규칙(BREAKDOWNS)을 그대로 읽어서 실제 결과 화면과 설명이 어긋나지 않는다. @@ -32,112 +24,169 @@ export function UpcomingResults({ edition: e }: { edition: Edition & { resultsAt ] const method = [ { - icon: 'chartBar', title: '문항마다 비율로 정리해요', body: '마감 뒤 응답을 모아 선택지별 비율과 척도 평균을 계산해요. 공개되는 건 이 집계뿐이고, 원본 응답은 공개하지 않아요.', }, { - icon: 'users', title: '집단별로 나눠 봐요', body: `${BREAKDOWNS.map((b) => `${b.label}(${b.groups.map((g) => g.label).join('·')})`).join(', ')}로 나눠 같은 문항의 답을 비교할 수 있어요.`, }, { - icon: 'messageCircle', title: '자유 의견은 따로 정리해요', body: '차트로 보여 주지 않아요. 이름이나 회사명처럼 특정인을 알아볼 수 있는 정보를 먼저 지워요.', }, - ] as const + ] + const stages = [ + { label: '응답 받는 중', date: e.opensAt && `${koDate(e.opensAt)} 시작` }, + { label: '마감', date: e.closesAt && koDate(e.closesAt) }, + { label: '결과 공개', date: koDate(e.resultsAt) }, + ] + const now = open ? 0 : 1 return (
-
-
+
+
{koDate(e.resultsAt)} 공개 - {e.title} 결과 - + + {e.title} 결과 + + {open ? `지금 응답을 모으고 있어요. 마감 뒤 분석을 거쳐 ${koDate(e.resultsAt)}에 누구나 볼 수 있게 공개해요.` : `응답을 마감하고 분석하고 있어요. ${koDate(e.resultsAt)}에 누구나 볼 수 있게 공개해요.`} {open && ( - - 설문 참여하기 + + 설문 참여하기 )} -
+
+
    + {stages.map((s, i) => ( +
  1. +
  2. + ))} +
+ -
- +
+
+ 어떻게 분석하나요 -
    - {method.map((m) => ( +
      + {method.map((m, i) => (
    1. - - {m.title} - - {m.body} - + + + {m.title} + + {m.body}
    2. ))} -
-
+ +
+ - {sections.length > 0 && ( -
-
- + {sections.length > 0 && ( +
+
+
+ 무엇을 공개하나요 - 설문에서 물은 주제를 모두 공개해요. + 설문에서 물은 주제를 모두 공개해요.
-
    +
      {sections.map(([id, s]) => (
    • -
      - {s.title} - - {s.count}문항 - -
      + + {s.title} + + + {s.count}문항 +
    • ))}
    -
- )} - -
- - 읽을 때 알아 두세요 - - - 스스로 참여한 분들의 답이라서 한국 개발자 전체를 그대로 대표하지는 않아요. 결과 화면에는 문항마다 몇 명이 답했는지 함께 적어요. - +
-
+ )} + +
+
+ +
+ + 읽을 때 알아 두세요 + + + 스스로 참여한 분들의 답이라서 한국 개발자 전체를 그대로 대표하지는 않아요. 결과 화면에는 문항마다 몇 명이 답했는지 함께 적어요. + +
+
+
diff --git a/src/lib/brand.ts b/src/lib/brand.ts index 4999d1d..4d10b4e 100644 --- a/src/lib/brand.ts +++ b/src/lib/brand.ts @@ -1,14 +1,5 @@ // dalestudy.com 에서 가져온 teal→violet 브랜드 그라데이션(dalestudy.com src/styles/site.css). -// 홈과 회차 페이지의 첫 화면, 강조 글자, 띠에 쓴다. - -// 첫 화면 배경. 아래로 갈수록 배경색에 녹아든다. 절대 위치로 깔고 내용은 그 위에 둔다. -export const heroWash: React.CSSProperties = { - position: 'absolute', - inset: 0, - pointerEvents: 'none', - background: - 'linear-gradient(180deg, transparent -20%, var(--colors-app-bg) 62%), linear-gradient(90deg, rgba(36, 234, 202, 0.28) 0%, rgba(132, 109, 233, 0.28) 100%)', -} +// 로고, 큰 제목의 강조 글자, 마지막 참여 띠에만 쓴다. 여러 곳에 쓰면 강조로 읽히지 않는다. // 제목 안의 강조 글자. 큰 제목에만 쓴다(시작색 대비가 약 3.3:1). export const gradientText: React.CSSProperties = { @@ -20,17 +11,51 @@ export const gradientText: React.CSSProperties = { export const gradientBanner = 'linear-gradient(135deg, #0e8e7b 0%, #6d51d6 100%)' -// 테두리만 그라데이션. 안쪽은 바탕색으로 채운다. -export const gradientBorder: React.CSSProperties = { - border: '1px solid transparent', - background: `linear-gradient(var(--colors-app-bg), var(--colors-app-bg)) padding-box, ${gradientBanner} border-box`, +// 홈과 회차 페이지 첫 화면의 제목. 휴대폰에서는 줄어든다. +export const heroTitle: React.CSSProperties = { + margin: 0, + fontSize: 'clamp(2.25rem, 4vw + 1rem, 3.75rem)', + lineHeight: 1.18, + fontWeight: 800, + letterSpacing: '-0.03em', + color: 'var(--colors-fg-neutral-active)', +} + +// 섹션 제목과 그 위의 머리말("왜 하나요" 같은 짧은 말) +export const sectionTitle: React.CSSProperties = { + margin: 0, + fontSize: 'clamp(1.75rem, 2vw + 1rem, 2.5rem)', + lineHeight: 1.3, + fontWeight: 800, + letterSpacing: '-0.025em', + color: 'var(--colors-fg-neutral-active)', } +export const eyebrow: React.CSSProperties = { margin: 0, fontSize: '0.875rem', fontWeight: 700, letterSpacing: '0.04em', color: 'var(--colors-fg-brand)' } -export const heroImage: React.CSSProperties = { +// 첫 화면 일러스트를 옅은 브랜드 면 위의 액자로 감싼다 +export const heroFrame: React.CSSProperties = { flex: '1 1 420px', minWidth: 0, - width: '100%', - height: 'auto', - borderRadius: 'var(--radii-lg)', - boxShadow: '0 12px 40px rgba(83, 51, 225, 0.12)', + margin: 0, + padding: 'var(--spacing-12)', + borderRadius: 24, + background: 'var(--colors-bg-brand)', + border: '1px solid var(--colors-bg-brand-active)', +} +export const heroImage: React.CSSProperties = { display: 'block', width: '100%', height: 'auto', borderRadius: 'var(--radii-lg)' } + +// 주 행동 링크. 버튼처럼 보이지만 페이지를 옮기므로 다. +export const ctaLink: React.CSSProperties = { + display: 'inline-flex', + gap: 'var(--spacing-8)', + alignItems: 'center', + justifyContent: 'center', + minHeight: 56, + padding: '0 28px', + borderRadius: 'var(--radii-md)', + background: 'var(--colors-bg-solid-brand)', + color: 'var(--colors-fg-solid-brand)', + fontSize: '1.125rem', + fontWeight: 600, + textDecoration: 'none', } diff --git a/src/routes/$edition.index.tsx b/src/routes/$edition.index.tsx index e64a436..679b671 100644 --- a/src/routes/$edition.index.tsx +++ b/src/routes/$edition.index.tsx @@ -5,7 +5,7 @@ import { SiteFooter } from '@/components/SiteFooter' import { SiteHeader } from '@/components/SiteHeader' import { findEdition, isBeta, isClosed } from '@/editions' import { koDate } from '@/lib/date' -import { gradientBorder, gradientText, heroImage, heroWash } from '@/lib/brand' +import { ctaLink, eyebrow, gradientText, heroFrame, heroImage, heroTitle } from '@/lib/brand' import { SITE_URL, ogImage } from '@/lib/og' import { sectionIcon } from '@/questions/sectionIcons' import { questionsFor } from '@/questions/survey' @@ -37,19 +37,12 @@ export const Route = createFileRoute('/$edition/')({ }) const wrap: React.CSSProperties = { maxWidth: 1120, margin: '0 auto', boxSizing: 'border-box' } +const rule = '1px solid var(--colors-bg-neutral-hover)' +const brandRule = '1px solid var(--colors-bg-brand-active)' +const sectionHeading: React.CSSProperties = { fontSize: 'clamp(1.625rem, 1.5vw + 1rem, 1.875rem)', fontWeight: 800, letterSpacing: '-0.025em' } -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', -} +// 올해의 주제. 주제 목록에서 큰 칸으로 떼어 낸다. +const FEATURED = 'ai' function EditionPage() { const e = Route.useLoaderData() @@ -57,6 +50,7 @@ function EditionPage() { const result = RESULTS[e.id] const questions = questionsFor(e.id) ?? [] const topics = [...new Map(questions.filter((q) => q.sectionId !== 'closing').map((q) => [q.sectionId, { id: q.sectionId, title: q.section }])).values()] + const featured = topics.find((t) => t.id === FEATURED) const steps = [ e.opensAt && { label: '시작', date: koDate(e.opensAt), note: '응답을 받기 시작해요' }, e.closesAt && { label: '마감', date: koDate(e.closesAt), note: '밤 11시 59분까지 받아요' }, @@ -64,150 +58,245 @@ function EditionPage() { ].filter((step) => !!step) const current = open ? 0 : result ? steps.length - 1 : Math.min(1, steps.length - 1) - const status = result - ? `${result.total.toLocaleString()}명이 답했어요` - : open - ? e.closesAt && `${koDate(e.closesAt)} 마감` - : e.resultsAt && `${koDate(e.resultsAt)}에 결과를 공개해요` - return (
-
-
)} {topics.length > 0 && ( -
-
-
- +
+
+
+ 무엇을 묻나요 - + {topics.length}가지 주제를 물어요. 본인 상황에 맞는 질문만 나와서 10분 안팎이면 끝나요.
-
    - {topics.map((t) => { - const highlight = t.id === 'ai' - return ( -
  • + {featured && ( +
    + - - {t.title} -
  • - ) - })} -
+ + +
+ 올해의 주제 + {featured.title.replace(/^올해의 주제\s*/, '')} +
+
+ )} +
    + {topics + .filter((t) => t.id !== FEATURED) + .map((t) => ( +
  • + + + + {t.title} +
  • + ))} +
+
)} + {e.devTypes && ( -
-
-
- - 끝까지 답하면 나의 개발자 유형을 알려 드려요 +
+
+
+

끝까지 답하면

+ + 나의 개발자 유형을 알려 드려요 - 여섯 가지 유형 가운데 하나와 함께, 2027년 커리어를 내다보는 한 줄도 받아 볼 수 있어요. 친구에게 공유해 보세요. + + 여섯 가지 유형 가운데 하나와 함께, 2027년 커리어를 내다보는 한 줄도 받아 볼 수 있어요. 친구에게 공유해 보세요. +
@@ -215,10 +304,10 @@ function EditionPage() { )}
-
+
이 설문조사를 왜 하는지, 응답을 어떻게 다루는지 궁금하다면 - - 달레 스터디 개발자 설문조사 소개 보기 + + 달레 스터디 개발자 설문조사 소개 보기
diff --git a/src/routes/$edition.results.tsx b/src/routes/$edition.results.tsx index 17276c5..f0e233e 100644 --- a/src/routes/$edition.results.tsx +++ b/src/routes/$edition.results.tsx @@ -1,5 +1,6 @@ import { Link, createFileRoute, notFound } from '@tanstack/react-router' import { Heading, Icon, Tag, Text } from 'daleui' +import { useEffect, useState } from 'react' import { findEdition } from '@/editions' import { SiteFooter } from '@/components/SiteFooter' import { SiteHeader } from '@/components/SiteHeader' @@ -35,29 +36,49 @@ const percent = (share: number) => `${Math.round(share * 100)}%` function QuestionResult({ q, breakdown }: { q: QuestionStat; breakdown?: Breakdown }) { const groups = breakdown?.questions[q.id] + if (q.hidden) { + return ( +
+ +
+ + {q.label} + + 응답자가 {MIN_GROUP}명보다 적어서 숨겼어요. 누가 답했는지 알아볼 수 없게 하려고요. +
+
+ ) + } return ( -
-
- - {q.label} - - - {q.answered.toLocaleString()}명이 답했어요.{q.type === 'multi' ? ' 여러 개 고를 수 있어서 합이 100%를 넘어요.' : ''} - {q.mean !== undefined ? ` 평균 ${q.mean.toFixed(1)}점이에요.` : ''} - -
- {q.hidden ? ( -
- - 응답자가 {MIN_GROUP}명보다 적어서 숨겼어요. 누가 답했는지 알아볼 수 없게 하려고요. +
+
+
+ + {q.label} + + + {q.answered.toLocaleString()}명이 답했어요.{q.type === 'multi' ? ' 여러 개 고를 수 있어서 합이 100%를 넘어요.' : ''} +
- ) : q.type === 'long' ? ( - 자유 의견은 이름이나 회사명처럼 특정인을 알아볼 수 있는 정보를 지운 뒤 따로 정리해요. + {q.mean !== undefined && ( +
+ + 평균 + + + {q.mean.toFixed(1)} + / 5 + +
+ )} +
+ {q.type === 'long' ? ( + 자유 의견은 이름이나 회사명처럼 특정인을 알아볼 수 있는 정보를 지운 뒤 따로 정리해요. ) : ( <> -
- 표로 보기 +
+ 표로 보기 @@ -106,6 +127,15 @@ function QuestionResult({ q, breakdown }: { q: QuestionStat; breakdown?: Breakdo ) } +const card: React.CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: 'var(--spacing-20)', + padding: 'clamp(16px, 4vw, 28px)', + borderRadius: 'var(--radii-lg)', + border: '1px solid var(--colors-bg-neutral-hover)', +} + const cell: React.CSSProperties = { padding: 'var(--spacing-4) var(--spacing-12)', borderBottom: '1px solid var(--colors-bg-neutral-hover)', @@ -116,102 +146,216 @@ const cell: React.CSSProperties = { function BreakdownPicker({ breakdowns, active }: { breakdowns: Breakdown[]; active?: string }) { const options = [{ id: undefined, label: '전체' }, ...breakdowns.map((b) => ({ id: b.id, label: b.label }))] return ( - + +
+ {sections.map(([sectionId, section]) => ( +
+
+ + + + + {section} + +
+ {result.questions + .filter((q) => q.sectionId === sectionId) + .map((q) => ( + + ))} +
+ ))} + +
+ + 어떻게 조사했나요 + + + 온라인 익명 설문이에요. 이름, 이메일, 로그인 정보는 수집하지 않았고 만 14세 이상만 참여했어요. 응답자가 {MIN_GROUP}명보다 적은 문항은 + 숨겼어요. 스스로 참여한 분들의 답이라서 한국 개발자 전체를 그대로 대표하지는 않아요. + +
+
) } + +const rule = '1px solid var(--colors-bg-neutral-hover)' + +function Figure({ label, value, brand }: { label: string; value: string; brand?: boolean }) { + return ( +
+
{label}
+
+ {value} +
+
+ ) +} diff --git a/src/routes/$edition.survey.tsx b/src/routes/$edition.survey.tsx index b1386c7..72c734d 100644 --- a/src/routes/$edition.survey.tsx +++ b/src/routes/$edition.survey.tsx @@ -2,7 +2,7 @@ import { Link, createFileRoute, notFound } from '@tanstack/react-router' import { Button, Heading, Icon, Tag, Text } from 'daleui' import { useEffect, useRef, useState } from 'react' import { ConsentStep } from '@/components/ConsentStep' -import { QuestionStep } from '@/components/QuestionStep' +import { KEY_OPTIONS, QuestionStep } from '@/components/QuestionStep' import { Turnstile } from '@/components/Turnstile' import { DevTypeCard } from '@/components/DevTypeCard' import { DevTypeGrid } from '@/components/DevTypeGrid' @@ -15,7 +15,7 @@ import { findEdition, isBeta, isClosed } from '@/editions' import { koDate } from '@/lib/date' import { OTHER, isEmpty, otherKey, visibleQuestions } from '@/questions/logic' import { clearProgress, loadProgress, saveProgress } from '@/questions/progress' -import { type Answers, questionsFor } from '@/questions/survey' +import { type Answers, SCALE_POINTS, questionsFor } from '@/questions/survey' import { submitResponse } from '@/server/functions/responses' export const Route = createFileRoute('/$edition/survey')({ @@ -46,14 +46,6 @@ const column: React.CSSProperties = { gap: 'var(--spacing-24)', } -const finished: React.CSSProperties = { - maxWidth: 560, - margin: '0 auto', - padding: '72px 24px 96px', - display: 'flex', - flexDirection: 'column', - gap: 'var(--spacing-40)', -} const quietAction: React.CSSProperties = { display: 'inline-flex', gap: 'var(--spacing-4)', @@ -69,10 +61,24 @@ const quietAction: React.CSSProperties = { cursor: 'pointer', } +const bar: React.CSSProperties = { maxWidth: 1120, margin: '0 auto', boxSizing: 'border-box' } + +// 아래 버튼 줄. 데스크톱은 이전 | 건너뛸게요 ... 다음, 휴대폰은 다음을 넓게(1:2) 두고 건너뛸게요를 위로 올린다. +const SURVEY_CSS = ` +.q-actions { display: flex; flex-wrap: wrap; gap: var(--spacing-8); align-items: center; } +.q-next { margin-left: auto; min-width: 140px; } +@media (max-width: 640px) { + .q-actions { display: grid; grid-template-columns: 1fr 2fr; } + .q-skip { grid-column: 1 / -1; order: -1; } + .q-next { margin-left: 0; min-width: 0; } + .q-step-label, .q-saved { display: none !important; } +} +` + // 응답을 마쳤거나 마감된 화면. 설문 진행 중이 아니라서 다른 화면으로 갈 수 있게 헤더와 하단을 둔다. function EndPage({ children }: { children: React.ReactNode }) { return ( -
+
{children}
@@ -91,14 +97,15 @@ function Finished({ saved, title, lead, devType, edition, onRetake }: { onRetake?: () => void }) { const leadText = ( - + {lead} ) + const wrap: React.CSSProperties = { maxWidth: devType ? 1120 : 640, margin: '0 auto', padding: '0 24px', boxSizing: 'border-box' } return ( -
-
+
+
{saved && (
@@ -107,51 +114,60 @@ function Finished({ saved, title, lead, devType, edition, onRetake }: {
)} - {title} + + {title} + {!devType && leadText} -
+
{devType && ( - <> - - - {leadText} - -
- - 여섯 가지 유형 모두 보기 - - -
- +
+
+ +
+ +
)} -
+ {devType && ( +
- - 처음으로 - - {onRetake && ( -
- 베타 - -
- )} -
+
+ + 여섯 가지 유형 모두 보기 + + +
+ + )} +
+ + 처음으로 + + {onRetake && ( +
+ 베타 + +
+ )}
) } +const panel: React.CSSProperties = { padding: 28, borderRadius: 20 } + function Survey() { const edition = Route.useLoaderData() const QUESTIONS = questionsFor(edition.id)! @@ -191,6 +207,24 @@ function Survey() { const pos = visible.findIndex((q) => q.id === qid) const current = pos >= 0 ? visible[pos] : undefined + // 숫자 키(1~9)로 선택지를 고른다. 입력란에 쓰는 중이면 그 글자로 둔다. + useEffect(() => { + if (phase !== 'question' || !current || current.type === 'long') return + const q = current + const codes = q.type === 'scale' ? SCALE_POINTS : q.options.slice(0, KEY_OPTIONS).map((o) => o.code) + function onKey(e: KeyboardEvent) { + if (e.metaKey || e.ctrlKey || e.altKey || e.repeat) return + if (e.target instanceof Element && e.target.closest('input, textarea, select, [contenteditable="true"]')) return + const n = Number(e.key) + if (!Number.isInteger(n) || n < 1 || n > codes.length) return + e.preventDefault() + if (q.type === 'multi') toggle(codes[n - 1]) + else pick(codes[n - 1]) + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [phase, current, answers]) + async function submit(final: Answers) { if (!turnstileToken) { setError('자동 입력 방지 확인이 끝나면 제출할 수 있어요. 잠시만 기다려 주세요.') @@ -240,6 +274,25 @@ function Survey() { setError('') } + function pick(code: string) { + if (!current) return + const next = { ...answers, [current.id]: code } + update(next) + // 기타는 직접 적을 수 있게 머문다 + if (code !== OTHER) timer.current = setTimeout(() => advance(next), AUTO_ADVANCE_MS) + } + + function toggle(code: string) { + if (!current) return + const picked = answers[current.id] + const list = Array.isArray(picked) ? picked : [] + if (!list.includes(code) && current.limit && list.length >= current.limit) { + setError(`최대 ${current.limit}개까지 고를 수 있어요. 다른 걸 먼저 빼 주세요.`) + return + } + update({ ...answers, [current.id]: list.includes(code) ? list.filter((c) => c !== code) : [...list, code] }) + } + if (phase === 'done') { return ( [q.sectionId, q.section])).entries()].map(([id, title], i, all) => { + const inSection = visible.filter((q) => q.sectionId === id) + const here = all.findIndex(([sid]) => sid === current.sectionId) + const done = inSection.length ? inSection.filter((q) => visible.indexOf(q) < pos).length / inSection.length : i < here ? 1 : 0 + return { id, title: title.replace(/^올해의 주제\s*/, ''), done, now: id === current.sectionId } + }) return ( -
-
-
-
-
- - {pos + 1} / {visible.length} - - { - const next = { ...answers, [current.id]: code } - update(next) - // 기타는 직접 적을 수 있게 머문다 - if (code !== OTHER) timer.current = setTimeout(() => advance(next), AUTO_ADVANCE_MS) - }} - onToggle={(code) => { - const picked = Array.isArray(value) ? value : [] - if (!picked.includes(code) && current.limit && picked.length >= current.limit) { - setError(`최대 ${current.limit}개까지 고를 수 있어요. 다른 걸 먼저 빼 주세요.`) - return - } - update({ ...answers, [current.id]: picked.includes(code) ? picked.filter((c) => c !== code) : [...picked, code] }) - }} - onText={(text) => update({ ...answers, [current.id]: text })} - otherText={typeof answers[otherKey(current.id)] === 'string' ? (answers[otherKey(current.id)] as string) : ''} - onOtherText={(text) => update({ ...answers, [otherKey(current.id)]: text })} - /> - {last && } -
- - - {!current.required && ( - +
+ +
+
+ + 그만하기 + + + 이 탭에 임시 저장돼요 + +
+
+ {sections.map((s) => ( +
+
+
+
+ +
+ ))} +
+
+ +
+
+ + 질문 {pos + 1} / {visible.length} + + update({ ...answers, [current.id]: text })} + otherText={typeof answers[otherKey(current.id)] === 'string' ? (answers[otherKey(current.id)] as string) : ''} + onOtherText={(text) => update({ ...answers, [otherKey(current.id)]: text })} + /> + {last && } +
+
+ +
+
+ {current.type === 'multi' && ( + + {picked.length} + {current.limit ? ` / ${current.limit}개 골랐어요` : '개 골랐어요. 여러 개 골라도 돼요.'} + )} +
+
+ +
+ {!current.required && ( +
+ +
+ )} +
+ +
+
-
-
+ +
) } diff --git a/src/routes/$edition.type.$slug.tsx b/src/routes/$edition.type.$slug.tsx index 0c6f273..c9ee1af 100644 --- a/src/routes/$edition.type.$slug.tsx +++ b/src/routes/$edition.type.$slug.tsx @@ -1,6 +1,7 @@ import { Link, createFileRoute, notFound } from '@tanstack/react-router' import { Heading, Text } from 'daleui' import { DevTypeCard } from '@/components/DevTypeCard' +import { ctaLink } from '@/lib/brand' import { DevTypeGrid } from '@/components/DevTypeGrid' import { SiteFooter } from '@/components/SiteFooter' import { SiteHeader } from '@/components/SiteHeader' @@ -43,23 +44,14 @@ function TypePage() {
- +
+ +
한국 개발자의 일과 커리어를 묻는 설문조사예요. 10분이면 나의 유형도 알 수 있어요. 나도 해 보기 diff --git a/src/routes/admin.index.tsx b/src/routes/admin.index.tsx index eae992e..81fb0cc 100644 --- a/src/routes/admin.index.tsx +++ b/src/routes/admin.index.tsx @@ -18,8 +18,9 @@ function Overview() {
{sections.map(([id, title]) => (
- - {title} + + {title}{' '} + · {summary.stats.filter((q) => q.sectionId === id).length}문항 {/* min(100%, …) 가 없으면 360px 휴대폰에서 카드가 화면 밖으로 넘친다 */}
@@ -40,7 +41,7 @@ function Distribution({ q }: { q: QuestionStat }) { const max = Math.max(1, ...options.map((o) => o.count)) const scaleLabel = (code: string) => (code === '1' ? `1 ${q.scale?.min ?? ''}` : code === '5' ? `5 ${q.scale?.max ?? ''}` : code) return ( -
+

{q.label}

@@ -64,10 +65,8 @@ function Distribution({ q }: { q: QuestionStat }) {
- - 열 고르기 ({shown.length}/{MAX_COLUMNS}) + + 열 고르기 + + {shown.length}/{MAX_COLUMNS} + +
{[...new Set(questions.map((q) => q.section))].map((section) => ( @@ -260,6 +264,29 @@ function Raw() {
)} + {filtering && ( +
+ + 걸린 필터 + + {table.state.globalFilter && ( + table.setGlobalFilter('')}> + 검색: {String(table.state.globalFilter)} + + )} + {table.state.columnFilters.map((f) => { + const q = questions.find((x) => x.id === f.id) + if (!q) return null + const label = choices(q).find((o) => o.code === f.value)?.label ?? String(f.value) + return ( + table.getColumn(f.id)?.setFilterValue(undefined)}> + {q.label}: {label} + + ) + })} +
+ )} + {filtering ? `${entries.length.toLocaleString()}건 중 ${filtered.toLocaleString()}건` : `${entries.length.toLocaleString()}건`} · 번호를 누르면 모든 답이 보여요 @@ -326,16 +353,16 @@ function Raw() { // 행 하나와, 펼쳤을 때 그 아래 이어지는 모든 답 function RowGroup({ expanded, colSpan, questions, answers, children }: { expanded: boolean; colSpan: number; questions: Question[]; answers: Answers; children: React.ReactNode }) { - const sections = [...new Set(questions.map((q) => q.section))] - .map((title) => ({ title, items: questions.filter((q) => q.section === title && answers[q.id] !== undefined) })) - .filter((s) => s.items.length > 0) + // 응답자에게 보였던 문항만. 보였는데 답이 없으면 건너뛴 것이다. + const shown = visibleQuestions(questions, answers) + const sections = [...new Set(shown.map((q) => q.section))].map((title) => ({ title, items: shown.filter((q) => q.section === title) })) return ( <>
{children} {expanded && (
-
+
{sections.map((s) => (
@@ -346,8 +373,12 @@ function RowGroup({ expanded, colSpan, questions, answers, children }: { expande
{q.label}
- {answerLabel(q, answers)} - {q.type !== 'long' && ( + {answers[q.id] === undefined ? ( + 건너뜀 + ) : ( + {answerLabel(q, answers)} + )} + {q.type !== 'long' && answers[q.id] !== undefined && ( {q.id}: {answerText(answers, q.id)} diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index 1a5fb3a..85dfce5 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -1,5 +1,6 @@ import { Link, Outlet, createFileRoute, useNavigate } from '@tanstack/react-router' -import { Heading, Select, Tag, Text } from 'daleui' +import { Heading, Icon, Select, Tag, Text } from 'daleui' +import { Logo } from '@/components/Logo' import { ADMIN_CSS, AdminDaily, AdminError } from '@/components/Admin' import { EDITIONS, findEdition, isBeta, isClosed } from '@/editions' import { adminFetch } from '@/lib/adminFetch' @@ -36,23 +37,33 @@ function AdminLayout() { const todayCount = summary.daily.find((d) => d.date === today)?.count ?? 0 const daysLeft = edition?.closesAt ? Math.ceil((new Date(edition.closesAt).getTime() - Date.now()) / 86_400_000) : undefined + // 응답 기간 중 며칠째인지. 시작일이 없으면 진행 막대를 그리지 않는다. + const day = (iso: string) => new Date(iso.length === 10 ? `${iso}T00:00:00+09:00` : iso).getTime() + const period = + edition?.opensAt && edition.closesAt + ? (() => { + const total = Math.ceil((day(edition.closesAt) - day(edition.opensAt)) / 86_400_000) + const elapsed = Math.min(total, Math.max(0, Math.ceil((Date.now() - day(edition.opensAt)) / 86_400_000))) + return { total, elapsed } + })() + : undefined + return ( -
+
-
-
-
-
- {edition && isBeta(edition) && 베타 응답} - - 운영진만 볼 수 있어요 - -
- +
+
+
+ + 응답 확인 + {edition && isBeta(edition) && 베타 응답} + + 운영진만 볼 수 있어요 +
-
+
{/* 회차를 바꾸면 보던 탭은 그대로 두고 원본 페이지는 처음으로 */}
-
- -
- - - {edition && daysLeft !== undefined && } -
- - - -
+ +
+ +
+
+ + + {edition && daysLeft !== undefined && ( + + {period && ( +
+ + 원본 응답은 공개하지 않아요. 이 화면의 데이터는 캐시되지 않아요. -
-
+ +
) } -function Stat({ label, value, brand }: { label: string; value: string; brand?: boolean }) { +function Stat({ label, value, unit, brand, note, children }: { label: string; value: string; unit?: string; brand?: boolean; note?: string; children?: React.ReactNode }) { return (
- {label} +
+ {label} + {note && {note}} +
{value} + {unit && {unit}} + {children}
) } +// 옅은 회색 바탕에 흰 패널을 얹어 테두리 없이 구획을 나눈다 const page: React.CSSProperties = { minHeight: '100vh', - boxSizing: 'border-box', - padding: 'var(--spacing-40) var(--spacing-24)', - background: 'var(--colors-app-bg)', + background: 'var(--colors-bg-neutral-hover)', color: 'var(--colors-fg-neutral)', } -const container: React.CSSProperties = { maxWidth: 1200, margin: '0 auto', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-32)' } +const bar: React.CSSProperties = { maxWidth: 1200, margin: '0 auto', padding: '0 var(--spacing-24)', boxSizing: 'border-box' } +const container: React.CSSProperties = { ...bar, paddingTop: 28, paddingBottom: 'var(--spacing-48)', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-20)' } diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 302ecfe..d93febb 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,10 +1,11 @@ import { Link, createFileRoute } from '@tanstack/react-router' -import { Card, Heading, Text } from 'daleui' +import { Heading, Icon, Text } from 'daleui' import { EditionList } from '@/components/EditionList' import { SiteFooter } from '@/components/SiteFooter' import { SiteHeader } from '@/components/SiteHeader' import { openEditions } from '@/editions' -import { gradientBanner, gradientText, heroImage, heroWash } from '@/lib/brand' +import { koDate } from '@/lib/date' +import { ctaLink, eyebrow, gradientBanner, gradientText, heroFrame, heroImage, heroTitle, sectionTitle } from '@/lib/brand' import { ogImage } from '@/lib/og' const TITLE = '달레 스터디 개발자 설문조사' @@ -29,161 +30,267 @@ export const Route = createFileRoute('/')({ component: Landing, }) +type IconName = React.ComponentProps['name'] + const wrap: React.CSSProperties = { maxWidth: 1120, margin: '0 auto', padding: '0 24px', boxSizing: 'border-box' } -const section: React.CSSProperties = { ...wrap, padding: '96px 24px', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-40)' } -const cta: React.CSSProperties = { - display: 'inline-flex', - alignItems: 'center', - justifyContent: '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', -} +const section: React.CSSProperties = { ...wrap, padding: '96px 24px', display: 'flex', flexDirection: 'column', gap: 'var(--spacing-48)' } +const rule = '1px solid var(--colors-bg-neutral-hover)' +const brandRule = '1px solid var(--colors-bg-brand-active)' + +const REASONS = [ + { + title: '국내 개발자 설문조사는 명맥이 끊겼어요', + body: '2023년 이후로 국내 개발자만을 대상으로 꾸준히 하는 설문조사가 없어요. 그래서 국내 기술이나 업계 동향을 파악하기가 어려워졌어요.', + }, + { + title: '한국 소프트웨어 개발 현실에 맞는 질문을 해요', + body: 'SI와 SM, 원청과 하청 구조, 파견 근무, 신입 공채, 원화 연봉처럼 글로벌 설문조사에서는 묻지 않는 것들이에요.', + }, +] + +const PROMISES: { icon: IconName; title: string; body: string }[] = [ + { icon: 'eyeOff', title: '익명으로 답해요', body: '이름, 이메일, 로그인 정보를 수집하지 않아요. 연봉처럼 답하기 망설여지는 질문은 건너뛰어도 돼요.' }, + { icon: 'globe', title: '결과는 모두 공개해요', body: '응답이 마감되면 결과를 누구나 볼 수 있게 공개하고, 분석한 인사이트도 함께 나눠요.' }, + { icon: 'heartHandshake', title: '통계에만 써요', body: '집계된 응답은 분석과 통계를 내는 데만 쓰고, 영리 목적으로는 쓰지 않아요.' }, +] + +// Stack Overflow 2025 설문조사 응답자 가운데 한국 개발자 +const KOREA = 86 +const GLOBAL = 49_009 function Landing() { // 지금 응답을 받는 회차. 없으면 결과로 안내한다. const open = openEditions()[0] + const share = KOREA / GLOBAL return (
-
-