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 (
-
-
-
);
};
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) => (
-