From f7a2fa4ee592e4ff65a70aa00f5ab2c3b53483ae Mon Sep 17 00:00:00 2001 From: Seongbin Kim Date: Mon, 13 Jul 2026 19:23:36 +0900 Subject: [PATCH] =?UTF-8?q?feat(web):=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=EC=A4=91=EC=8B=AC=20=EC=95=A0=EB=84=90=EB=A6=AC=ED=8B=B1?= =?UTF-8?q?=EC=8A=A4=20=EC=BD=95=ED=95=8F=20=EC=8B=9C=EC=95=88=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/components.json | 21 + apps/web/src/App.css | 56 +++ apps/web/src/components/auth/AuthHeader.tsx | 12 +- .../charts/AvailableRestTimeChart/chart.tsx | 175 +++++-- .../charts/AvailableRestTimeChart/points.tsx | 16 +- .../web/src/components/charts/DayRatioBar.tsx | 8 +- .../components/charts/ProductivePaceChart.tsx | 186 +++++-- apps/web/src/components/days/DayNavigator.tsx | 40 +- .../src/components/texts/ProductiveToggle.tsx | 32 +- .../src/components/texts/TextLogContainer.tsx | 246 +++++++--- apps/web/src/components/texts/TimeSummary.tsx | 26 +- apps/web/src/components/ui/badge.tsx | 31 ++ apps/web/src/components/ui/button.tsx | 57 +++ apps/web/src/components/ui/card.tsx | 57 +++ apps/web/src/components/ui/input.tsx | 20 + apps/web/src/components/ui/tabs.tsx | 79 +++ .../features/AvailableRestTimeChartArea.tsx | 15 +- .../src/features/ProductivePaceChartArea.tsx | 53 +- apps/web/src/features/theme/ThemeSelector.tsx | 27 +- apps/web/src/hooks/useCommandPalette.ts | 4 + apps/web/src/pages/LogWriterPage.tsx | 455 ++++++++++++++++-- apps/web/src/utils/PaceUtil.ts | 6 +- apps/web/src/utils/commandEvents.ts | 22 + 23 files changed, 1348 insertions(+), 296 deletions(-) create mode 100644 apps/web/components.json create mode 100644 apps/web/src/components/ui/badge.tsx create mode 100644 apps/web/src/components/ui/button.tsx create mode 100644 apps/web/src/components/ui/card.tsx create mode 100644 apps/web/src/components/ui/input.tsx create mode 100644 apps/web/src/components/ui/tabs.tsx diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 0000000..2eae697 --- /dev/null +++ b/apps/web/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/App.css", + "baseColor": "slate", + "cssVariables": false, + "prefix": "" + }, + "aliases": { + "components": "./src/components", + "utils": "./src/utils", + "ui": "./src/components/ui", + "lib": "./src/lib", + "hooks": "./src/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/apps/web/src/App.css b/apps/web/src/App.css index 8363bb1..de58ce3 100644 --- a/apps/web/src/App.css +++ b/apps/web/src/App.css @@ -2,6 +2,29 @@ @tailwind components; @tailwind utilities; +@layer base { + html, + body, + #root { + min-height: 100%; + } + + body { + margin: 0; + background: #f3f6fa; + color: #0f172a; + font-family: + 'IBM Plex Sans', 'Pretendard Variable', 'Pretendard', 'Aptos', sans-serif; + -webkit-font-smoothing: antialiased; + text-rendering: geometricPrecision; + } + + ::selection { + background: rgb(191 219 254 / 80%); + color: #172554; + } +} + @keyframes shake { 0% { transform: translateX(0); @@ -32,3 +55,36 @@ .shake-animation { animation: shake 600ms ease-in-out; } + +.chart-empty-grid { + background-image: + linear-gradient(to right, rgb(226 232 240 / 55%) 1px, transparent 1px), + linear-gradient(to bottom, rgb(226 232 240 / 55%) 1px, transparent 1px); + background-size: 32px 32px; + mask-image: linear-gradient( + to bottom, + transparent, + black 35%, + black 65%, + transparent + ); +} + +.cockpit-scrollbar { + scrollbar-color: #334155 transparent; + scrollbar-width: thin; +} + +.cockpit-scrollbar::-webkit-scrollbar { + width: 8px; +} + +.cockpit-scrollbar::-webkit-scrollbar-track { + background: transparent; +} + +.cockpit-scrollbar::-webkit-scrollbar-thumb { + border: 2px solid #020617; + border-radius: 999px; + background: #334155; +} diff --git a/apps/web/src/components/auth/AuthHeader.tsx b/apps/web/src/components/auth/AuthHeader.tsx index 16eb428..d845b67 100644 --- a/apps/web/src/components/auth/AuthHeader.tsx +++ b/apps/web/src/components/auth/AuthHeader.tsx @@ -9,7 +9,7 @@ import { AuthModal } from './AuthModal'; type AuthModalMode = 'login' | 'signup'; -export const AuthHeader = () => { +export const AuthHeader = ({ compact = false }: { compact?: boolean }) => { const dispatch = useDispatch(); const navigate = useNavigate(); const { isAuthenticated, username } = useSelector( @@ -35,7 +35,8 @@ export const AuthHeader = () => { @@ -50,16 +51,19 @@ export const AuthHeader = () => { } return ( -
+
    { const data = getChartDataPoints(logs); + + if (data.length === 0) { + return ( +
    +
    +
    +
    + + + + +
    +

    + 밸런스를 계산할 기록이 없습니다 +

    +

    + 생산과 소비 기록이 쌓이면 누적 차이를 시각화합니다. +

    +
    +
    + ); + } + const gradientOffset = calculateGradientOffset(data); const yAxisConfig = getNormalizedYAxisTicks(data); @@ -32,49 +56,112 @@ export const AvailableRestTimeChart = ({ const gradientId = 'availableRestTimeSplitColor'; return ( - - - - - `${value}`} - /> - [`${value}분`, '초과 휴식']} - /> - - - - - - - - {getPoints(data)} - - +
    +
    + + 생산 우위 + + + 소비 초과 + + + + 균형선 + +
    + + + + + `${value}m`} + tickLine={false} + axisLine={false} + tickMargin={8} + width={42} + /> + + `시각 ${minutesToTimeString(Number(label))}` + } + formatter={(value: number | string) => { + const minutes = Number(value); + return [ + `${Math.abs(minutes)}분`, + minutes > 0 ? '소비 초과' : '생산 우위', + ]; + }} + /> + + + + + + + + + {getPoints(data)} + + +
    ); }; diff --git a/apps/web/src/components/charts/AvailableRestTimeChart/points.tsx b/apps/web/src/components/charts/AvailableRestTimeChart/points.tsx index 0016284..ef5373e 100644 --- a/apps/web/src/components/charts/AvailableRestTimeChart/points.tsx +++ b/apps/web/src/components/charts/AvailableRestTimeChart/points.tsx @@ -11,7 +11,7 @@ export const getPoints = (data: ChartDataPoint[]) => { const points: ReactElement[] = []; if (highPoint) { - const color = highPoint.need >= 0 ? 'red' : 'green'; + const color = highPoint.need >= 0 ? '#f97316' : '#06b6d4'; points.push( { value={formatMinutesWithSign(highPoint.need)} position="top" fill={color} - fontSize={14} + fontSize={11} fontWeight="bold" /> , @@ -34,7 +34,7 @@ export const getPoints = (data: ChartDataPoint[]) => { } if (lowPoint) { - const color = lowPoint.need >= 0 ? 'red' : 'green'; + const color = lowPoint.need >= 0 ? '#f97316' : '#06b6d4'; points.push( { value={formatMinutesWithSign(lowPoint.need)} position="bottom" fill={color} - fontSize={14} + fontSize={11} fontWeight="bold" /> , @@ -71,7 +71,7 @@ export const getPoints = (data: ChartDataPoint[]) => { value={formatMinutesWithSign(currentPointConfig.point.need)} position={currentPointConfig.position} fill={currentPointConfig.color} - fontSize={14} + fontSize={11} fontWeight="bold" /> , @@ -117,7 +117,7 @@ function isNearPoint( type CurrentPointConfig = { point: ChartDataPoint | null; shouldShow: boolean; - color: 'red' | 'green'; + color: '#f97316' | '#06b6d4'; position: 'top' | 'bottom'; }; @@ -131,14 +131,14 @@ function getCurrentPointConfig( return { point: null, shouldShow: false, - color: 'red', + color: '#f97316', position: 'top', }; } const shouldShow = !isNearPoint(currPoint, highPoint) && !isNearPoint(currPoint, lowPoint); - const color = currPoint.need >= 0 ? 'red' : 'green'; + const color = currPoint.need >= 0 ? '#f97316' : '#06b6d4'; const position = currPoint.need >= 0 ? 'top' : 'bottom'; return { point: currPoint, shouldShow, color, position }; diff --git a/apps/web/src/components/charts/DayRatioBar.tsx b/apps/web/src/components/charts/DayRatioBar.tsx index 4812d71..733ac64 100644 --- a/apps/web/src/components/charts/DayRatioBar.tsx +++ b/apps/web/src/components/charts/DayRatioBar.tsx @@ -8,7 +8,7 @@ export const DayRatioBar = ({ logs }: { logs: Log[] }) => { if (isEmpty || totalDuration <= 0) { return ( -
    +
    ); } @@ -36,14 +36,14 @@ export const DayRatioBar = ({ logs }: { logs: Log[] }) => { return (
    {blocks.map((block, idx) => (
    { + if (data.length === 0) { + return ( +
    +
    +
    +
    +
    +
    +

    + 페이스 데이터가 아직 없습니다 +

    +

    + 첫 생산 활동을 기록하면 시간대별 흐름이 나타납니다. +

    +
    +
    + ); + } + + const firstOffset = data[0].offset; + const lastOffset = data[data.length - 1].offset; + const domainStart = Math.max(0, Math.floor((firstOffset - 30) / 60) * 60); + const domainEnd = Math.max( + domainStart + 60, + Math.ceil((lastOffset + 30) / 60) * 60, + ); + const paceCeiling = Math.max( + 60, + targetPace + 10, + todayAvg + 10, + ...data.map((log) => log.pace + 10), + ); + return ( - - - - - - - - +
    + + 생산 페이스 + + + + 오늘 평균 {todayAvg} + + + + 목표 {targetPace} + +
    + + - - o.pace} - unit="min/h" - stroke="darkgreen" - fill="green" - /> - - + > + + + + + + + + + + + `시각 ${minutesToTimeString(Number(label))}` + } + formatter={(value: number | string) => [ + `${value}분 / 시간`, + '생산 페이스', + ]} + /> + + + +
    +
    +
    ); }; diff --git a/apps/web/src/components/days/DayNavigator.tsx b/apps/web/src/components/days/DayNavigator.tsx index ecb1d13..2839f3b 100644 --- a/apps/web/src/components/days/DayNavigator.tsx +++ b/apps/web/src/components/days/DayNavigator.tsx @@ -1,6 +1,7 @@ import { useDispatch } from 'react-redux'; import { goToNextDate, goToPrevDate, goToToday } from '../../store/logs'; +import { Button } from '../ui/button'; const PREV_DAY_BUTTON_TEXT = '←'; const TODAY_BUTTON_TEXT = '오늘'; @@ -13,19 +14,36 @@ export const DayNavigator = () => { const handleTomorrowButton = () => dispatch(goToNextDate()); return ( -
    - - + + - + {NEXT_DAY_BUTTON_TEXT} +
    ); }; diff --git a/apps/web/src/components/texts/ProductiveToggle.tsx b/apps/web/src/components/texts/ProductiveToggle.tsx index b1c9016..24aa575 100644 --- a/apps/web/src/components/texts/ProductiveToggle.tsx +++ b/apps/web/src/components/texts/ProductiveToggle.tsx @@ -14,22 +14,36 @@ export const ProductiveToggle = ({ onKeyDown, checkboxRef, }: ProductiveToggleProps) => ( -