From 71e5131aa14a6c836b089a2ffdf52fa5ef7c08ab Mon Sep 17 00:00:00 2001 From: Adrian Rumpold Date: Thu, 10 Jul 2025 12:19:07 +0200 Subject: [PATCH] Align layout with Figma design --- index.html | 2 - main.html | 0 public/vite.svg | 1 - src/App.tsx | 31 +++--- src/components/Instructions.tsx | 6 +- src/components/QRCode.tsx | 8 +- ...QuestionGroupChart.tsx => WaffleChart.tsx} | 61 ++++++++---- src/config.ts | 9 +- src/styles/App.scss | 64 ++++++------ .../Chart.css => styles/Chart.scss} | 6 +- src/styles/Legend.scss | 9 +- src/styles/QRCode.scss | 97 +++++++++++++++++-- src/styles/_shared.scss | 39 ++------ src/styles/index.scss | 26 ++--- 14 files changed, 218 insertions(+), 141 deletions(-) create mode 100644 main.html delete mode 100644 public/vite.svg rename src/components/{QuestionGroupChart.tsx => WaffleChart.tsx} (60%) rename src/{components/Chart.css => styles/Chart.scss} (79%) diff --git a/index.html b/index.html index 464b5c7..30fd55a 100644 --- a/index.html +++ b/index.html @@ -7,8 +7,6 @@ - - \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 191e80d..7d9cef4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,15 +1,14 @@ import { useQuery } from "@tanstack/react-query"; import * as d3 from "d3"; -import { config } from "./config"; -import { getSampleData } from "./lib/data"; import { fetchCategoryMetadata } from "./lib/metadata"; import { Instructions } from "./components/Instructions"; import Legend from "./components/Legend"; import QRCode from "./components/QRCode"; -import { QuestionGroupChart } from "./components/QuestionGroupChart"; +import { WaffleChart } from "./components/WaffleChart"; +import { fetchGoogleSheet } from "./lib/parser"; import "./styles/App.scss"; function App() { @@ -19,8 +18,9 @@ function App() { }); const responseQuery = useQuery({ queryKey: ["responses"], - queryFn: getSampleData, - refetchInterval: 2 * 1000, // Refresh every 5 seconds + queryFn: fetchGoogleSheet, + // queryFn: getSampleData, + refetchInterval: 5 * 1000, // Refresh every 5 seconds }); if (metadataQuery.isPending || responseQuery.isPending) @@ -28,7 +28,10 @@ function App() { if (metadataQuery.isError || responseQuery.isError) return
Error loading data
; - const responses = responseQuery.data; + // Sort responses by timestamp to easily find the latest response + const responses = responseQuery.data.sort( + (a, b) => a.timestamp - b.timestamp + ); const categoryMetadata = metadataQuery.data; if (!responses.length) return null; @@ -38,27 +41,21 @@ function App() { d3.group(responses, (d) => d.question).entries() ); - // Get unique response categories (sorted for consistent ordering) - const allResponses = [...new Set(responses.map((d) => d.response))]; - const sortedResponses = allResponses.sort((a, b) => a - b); - // Create scales - const xScale = d3 - .scaleBand() - .domain(sortedResponses) - .range([0, sortedResponses.length * config.groupSpacing]); return (
{questionGroups.map(([question, groupData]) => ( - m.category === question)} groupData={groupData} - responses={sortedResponses} - xScale={xScale} + responses={[...new Set(responses.map((d) => d.response))]} + latestResponseTimestamp={ + responses[responses.length - 1].timestamp + } /> ))}
diff --git a/src/components/Instructions.tsx b/src/components/Instructions.tsx index b591cc7..b15b9d9 100644 --- a/src/components/Instructions.tsx +++ b/src/components/Instructions.tsx @@ -1,7 +1,7 @@ export function Instructions() { return ( -
- Instructions -
+ <> + Instructions + ); } diff --git a/src/components/QRCode.tsx b/src/components/QRCode.tsx index 8d9fdd4..931134e 100644 --- a/src/components/QRCode.tsx +++ b/src/components/QRCode.tsx @@ -8,8 +8,12 @@ export default function QRCode() { Scanne den Code und zeige, welche KI-Skills du mitbringst — ganz egal, auf welchem Level du bist.

-
- +
+ QR Code
); diff --git a/src/components/QuestionGroupChart.tsx b/src/components/WaffleChart.tsx similarity index 60% rename from src/components/QuestionGroupChart.tsx rename to src/components/WaffleChart.tsx index a8b4dd2..36f2387 100644 --- a/src/components/QuestionGroupChart.tsx +++ b/src/components/WaffleChart.tsx @@ -1,25 +1,27 @@ import MaterialIcon from "@mui/material/Icon"; import * as d3 from "d3"; import { useEffect, useRef } from "react"; + import { colorScheme, config } from "../config"; - import { CategoryMetadata } from "../lib/metadata"; +import { ResponseData } from "../lib/parser"; -import "./Chart.css"; +import "../styles/Chart.scss"; interface QuestionGroupChartProps { question: string; metadata?: CategoryMetadata; - groupData: { response: number }[]; + groupData: ResponseData[]; responses: number[]; - xScale: d3.ScaleBand; + latestResponseTimestamp: number; } function makeDot( g: d3.Selection, dotX: number, dotY: number -) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): d3.Selection { const shape = config.dotShape; if (shape === "circle") { return g @@ -40,23 +42,30 @@ function makeDot( throw new Error(`Unsupported shape: ${shape}`); } -export function QuestionGroupChart({ +export function WaffleChart({ question, metadata, groupData, responses, - xScale, + latestResponseTimestamp, }: QuestionGroupChartProps) { const svgRef = useRef(null); useEffect(() => { + const sortedResponses = [...responses].sort((a, b) => a - b); + + const chartHeight = config.chartHeight; + const chartWidth = responses.length * config.groupSpacing; + const xScale = d3 + .scaleBand() + .domain([0, 1, 2, 3, 4]) + .range([0, chartWidth]); + // Clear SVG d3.select(svgRef.current).selectAll("*").remove(); // Group responses by category (within this group only) const responseGroups = d3.group(groupData, (d) => d.response); - const chartHeight = config.chartHeight; - const chartWidth = xScale.range()[1]; const svg = d3 .select(svgRef.current) @@ -64,25 +73,45 @@ export function QuestionGroupChart({ .attr("height", chartHeight); const g = svg.append("g"); + // Create x-axis + if (config.renderXAxis) { + const axisY = chartHeight - config.dotRadius - config.dotSpacing; + g.append("line") + .attr("x1", 0) + .attr("y1", axisY) + .attr("x2", chartWidth) + .attr("y2", axisY) + .attr("stroke", "#000") + .attr("stroke-width", 0.5) + .attr("stroke-dasharray", "2,2"); + } + // Dots - responses.forEach((response) => { + sortedResponses.forEach((response) => { const responseData = responseGroups.get(response) || []; - const x = xScale(response) || 0; - responseData.forEach((_, index) => { + // Use xScale(response) as the group center + const x = (xScale(response) || 0) + config.dotRadius; + responseData.forEach((entry, index) => { const row = Math.floor(index / config.columnsPerGroup); const col = index % config.columnsPerGroup; const dotX = x + - xScale.bandwidth() / 2 + (col - (config.columnsPerGroup - 1) / 2) * (config.dotRadius * 2 + config.dotSpacing); const dotY = chartHeight - (row + 1) * (config.dotRadius * 2 + config.dotSpacing); - makeDot(g, dotX, dotY).attr("fill", colorScheme[response] || "#666"); + const dot = makeDot(g, dotX, dotY).attr( + "fill", + colorScheme[response] || "#666" + ); + const isLatest = entry.timestamp === latestResponseTimestamp; + if (isLatest) { + dot.attr("class", "latest"); + } }); }); - }, [groupData, responses, xScale, question]); + }, [groupData, responses, question, latestResponseTimestamp]); return (
@@ -94,5 +123,3 @@ export function QuestionGroupChart({
); } - -export default QuestionGroupChart; diff --git a/src/config.ts b/src/config.ts index 5c90bff..7c5fefc 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,15 +1,16 @@ -const dotRadius = 8; -const dotSpacing = 2; +const dotRadius = 7; +const dotSpacing = 1; const columnsPerGroup = 3; -const groupGap = 8; +const groupGap = dotRadius; export const config = { dotRadius, dotSpacing, columnsPerGroup, groupSpacing: (2 * dotRadius + dotSpacing) * columnsPerGroup + groupGap, - chartHeight: 150, + chartHeight: 50, dotShape: "rect", // "circle" or "rect" + renderXAxis: true, // Whether to render the x-axis }; // Color scheme for Likert scale responses diff --git a/src/styles/App.scss b/src/styles/App.scss index 3b07768..11a3fa9 100644 --- a/src/styles/App.scss +++ b/src/styles/App.scss @@ -5,11 +5,11 @@ grid-template-areas: "main qr" "main instructions"; - grid-template-columns: auto 25%; - grid-template-rows: auto auto; - grid-gap: 25px; + grid-template-columns: auto 400px; + grid-template-rows: 1fr 1fr; + grid-gap: 36px; - margin: 25px; + margin: 75px 50px; } .chart-container { @@ -19,7 +19,10 @@ grid-area: main; display: flex; flex-direction: column; - padding: 50px; + align-items: center; + + padding: 75px 50px; + margin: 0; gap: 50px; background-color: $bg-grey; @@ -31,40 +34,45 @@ gap: 25px; } +.qr { + grid-area: qr; +} + +.instructions { + grid-area: instructions; +} + .question-group { - @include rounded; - @include shadow; + @include shadow-small; background-color: #ffffff; border-radius: 4px; - padding: 16px; - flex: 1 1 300px; + padding: 28px 14px; + + flex: 0 1 250px; display: flex; flex-direction: column; align-items: start; - gap: 8px; -} -.question-group svg { - align-self: center; - margin-bottom: 16px; -} + svg { + align-self: center; + } -.question-group p { - font-size: 14px; - color: #666; - line-height: 1.4; -} + p { + margin: 0; + } -.question-title { - font-size: 16px; - font-weight: bold; - color: #333; -} + .question-title { + margin-block: 13px; -.question-title .material-icons { - vertical-align: top; - margin-right: 1ex; + font-size: 15px; + font-weight: 600; + + .material-icons { + vertical-align: middle; + margin-right: 0.5rem; + } + } } diff --git a/src/components/Chart.css b/src/styles/Chart.scss similarity index 79% rename from src/components/Chart.css rename to src/styles/Chart.scss index d5eda82..5693658 100644 --- a/src/components/Chart.css +++ b/src/styles/Chart.scss @@ -1,10 +1,12 @@ +@use "shared" as *; + .dot { stroke: #fff; stroke-width: 1; } -.selected { - fill: rgb(223 110 38); +.latest { + fill: rgba(255, 125, 67, 1); //$aai-orange; } .axis-label { diff --git a/src/styles/Legend.scss b/src/styles/Legend.scss index 6947bf3..dd6eae8 100644 --- a/src/styles/Legend.scss +++ b/src/styles/Legend.scss @@ -6,18 +6,21 @@ width: 32px; height: 32px; border-radius: 4px; - margin-right: 8px; + margin-right: 5px; vertical-align: middle; @include shadow; } ul { list-style: none; - margin: 16px; + display: flex; flex-wrap: wrap; align-items: center; justify-content: center; - gap: 16px; + gap: 20px; + + padding: 0; + margin: 0; } } diff --git a/src/styles/QRCode.scss b/src/styles/QRCode.scss index ea21e55..9d9ddd0 100644 --- a/src/styles/QRCode.scss +++ b/src/styles/QRCode.scss @@ -2,13 +2,12 @@ .qr-code-container { @include rounded; + @include shadow; background-color: $aai-orange; - flex-basis: 20%; - flex-shrink: 0; - padding: 16px; + padding: 40px 25px; - color: #fff; + color: $bg-grey; display: flex; flex-direction: column; @@ -16,14 +15,92 @@ p { text-align: center; + font-size: 20px; } - img { - display: block; - margin: 0 auto; + .qr-code { + display: flex; + justify-content: center; + background-color: #fff; - border-radius: 16px; - padding: 16px; - width: 50%; + border-radius: $border-radius; + padding: 25px; + + img { + display: block; + width: 150px; + } } } + +.viewfinder { + $viewfinder-size: 24px; + $viewfinder-color: $aai-orange; + $viewfinder-width: 4px; + padding: $viewfinder-width; + background: + /* Top-left corner */ linear-gradient( + to right, + $viewfinder-color 0%, + $viewfinder-color $viewfinder-size, + transparent $viewfinder-size + ), + linear-gradient( + to bottom, + $viewfinder-color 0%, + $viewfinder-color $viewfinder-size, + transparent $viewfinder-size + ), + /* Top-right corner */ + linear-gradient( + to left, + $viewfinder-color 0%, + $viewfinder-color $viewfinder-size, + transparent $viewfinder-size + ), + linear-gradient( + to bottom, + $viewfinder-color 0%, + $viewfinder-color $viewfinder-size, + transparent $viewfinder-size + ), + /* Bottom-left corner */ + linear-gradient( + to right, + $viewfinder-color 0%, + $viewfinder-color $viewfinder-size, + transparent $viewfinder-size + ), + linear-gradient( + to top, + $viewfinder-color 0%, + $viewfinder-color $viewfinder-size, + transparent $viewfinder-size + ), + /* Bottom-right corner */ + linear-gradient( + to left, + $viewfinder-color 0%, + $viewfinder-color $viewfinder-size, + transparent $viewfinder-size + ), + linear-gradient( + to top, + $viewfinder-color 0%, + $viewfinder-color $viewfinder-size, + transparent $viewfinder-size + ); + + background-size: + /* Top-left */ 100% $viewfinder-width, + $viewfinder-width 100%, /* Top-right */ 100% $viewfinder-width, + $viewfinder-width 100%, /* Bottom-left */ 100% $viewfinder-width, + $viewfinder-width 100%, /* Bottom-right */ 100% $viewfinder-width, + $viewfinder-width 100%; + + background-position: + /* Top-left */ 0 0, 0 0, + /* Top-right */ 100% 0, 100% 0, /* Bottom-left */ 0 100%, 0 100%, + /* Bottom-right */ 100% 100%, 100% 100%; + background-repeat: no-repeat; +} diff --git a/src/styles/_shared.scss b/src/styles/_shared.scss index 39bd88f..5cab30c 100644 --- a/src/styles/_shared.scss +++ b/src/styles/_shared.scss @@ -9,44 +9,19 @@ $aai-orange: hsl(18.6deg 100% 55.1%); $bg-grey: #f8f9fa; +$border-radius: 30px; @mixin rounded { - border-radius: 16px; + border-radius: $border-radius; } @mixin shadow { - box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.25); + box-shadow: 0px 4px 4px 0 rgba(0, 0, 0, 0.25); +} + +@mixin shadow-small { + box-shadow: 0px 2px 2px 0 rgba(0, 0, 0, 0.25); } @mixin no-shadow { box-shadow: none; } - -$border-radius: 16px; - -@mixin button { - @include shadow; - - background-color: $aai-action-button; - color: #ffffff; - - border-radius: $border-radius; - cursor: pointer; - - display: flex; - align-items: center; - - overflow: hidden; - - &:active { - @include no-shadow; - } - - span { - flex: 1 auto; - white-space: nowrap; - text-align: center; - font-weight: bold; - text-transform: uppercase; - font-size: 16px; - } -} diff --git a/src/styles/index.scss b/src/styles/index.scss index de03bd5..f3c3258 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -1,21 +1,15 @@ @use "shared" as *; -@import "normalize-scss"; +@use "normalize-scss" as normalize; -@include normalize(); +@include normalize.normalize(); :root { - font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; + font-family: "Work Sans", system-ui, Avenir, Helvetica, Arial, sans-serif; font-weight: 400; color-scheme: only light; - color: #213547; + color: $aai-blue-dark; background-color: #ffffff; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; } h1, @@ -24,8 +18,8 @@ h3, h4, h5, h6 { - font-family: "Work Sans", sans-serif; - font-weight: 800; + font-weight: 600; + margin: 0; } a { @@ -36,11 +30,3 @@ a { a:hover { color: $aai-blue-light; } - -body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -}