Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/web/components.json
Original file line number Diff line number Diff line change
@@ -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"
}
56 changes: 56 additions & 0 deletions apps/web/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
12 changes: 8 additions & 4 deletions apps/web/src/components/auth/AuthHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -35,7 +35,8 @@ export const AuthHeader = () => {
<button
type="button"
onClick={() => openModal('login')}
className="btn btn-ghost btn-sm gap-1.5 rounded-lg font-medium text-base-content/70 transition-colors hover:bg-base-content/10 hover:text-base-content"
className={`btn btn-ghost btn-sm gap-1.5 rounded-lg font-medium text-base-content/70 transition-colors hover:bg-base-content/10 hover:text-base-content ${compact ? 'btn-square' : ''}`}
aria-label="로그인"
>
<LogIn size={15} />
</button>
Expand All @@ -50,16 +51,19 @@ export const AuthHeader = () => {
}

return (
<div className="dropdown dropdown-end">
<div
className={`dropdown ${compact ? 'dropdown-end dropdown-right' : 'dropdown-end'}`}
>
<button
type="button"
tabIndex={0}
className="btn btn-ghost btn-sm gap-2 rounded-lg font-medium text-base-content/70 transition-colors hover:bg-base-content/10 hover:text-base-content"
aria-label="계정 메뉴"
>
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-primary/10 text-primary">
<User size={14} />
</div>
<span className="hidden sm:inline">{username}</span>
{!compact && <span className="hidden sm:inline">{username}</span>}
</button>
<ul
tabIndex={0}
Expand Down
175 changes: 131 additions & 44 deletions apps/web/src/components/charts/AvailableRestTimeChart/chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
Area,
AreaChart,
CartesianGrid,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
Expand All @@ -23,6 +24,29 @@ export const AvailableRestTimeChart = ({
logs,
}: AvailableRestTimeChartProps) => {
const data = getChartDataPoints(logs);

if (data.length === 0) {
return (
<div className="relative flex h-full min-h-[260px] items-center justify-center overflow-hidden rounded-lg border border-dashed border-slate-200 bg-slate-50/60">
<div className="chart-empty-grid absolute inset-0 opacity-60" />
<div className="relative text-center">
<div className="mx-auto mb-3 flex h-6 w-12 items-end justify-center gap-1">
<span className="h-2 w-1.5 rounded-sm bg-cyan-300" />
<span className="h-4 w-1.5 rounded-sm bg-cyan-500" />
<span className="h-6 w-1.5 rounded-sm bg-blue-600" />
<span className="h-3 w-1.5 rounded-sm bg-orange-400" />
</div>
<p className="text-sm font-semibold text-slate-700">
밸런스를 계산할 기록이 없습니다
</p>
<p className="mt-1 text-xs text-slate-400">
생산과 소비 기록이 쌓이면 누적 차이를 시각화합니다.
</p>
</div>
</div>
);
}

const gradientOffset = calculateGradientOffset(data);
const yAxisConfig = getNormalizedYAxisTicks(data);

Expand All @@ -32,49 +56,112 @@ export const AvailableRestTimeChart = ({
const gradientId = 'availableRestTimeSplitColor';

return (
<ResponsiveContainer className="min-h-0">
<AreaChart
data={data}
margin={{
top: 30,
right: 30,
left: 0,
bottom: 30,
}}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="offset"
type="number"
tick={{ fontSize: 16 }}
tickFormatter={minutesToTimeString}
domain={[minXAxisDomain, maxXAxisDomain]}
/>
<YAxis
tick={{ fontSize: 16 }}
domain={yAxisConfig.domain}
ticks={yAxisConfig.ticks}
tickFormatter={(value) => `${value}`}
/>
<Tooltip
labelFormatter={minutesToTimeString}
formatter={(value: number | string) => [`${value}분`, '초과 휴식']}
/>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset={gradientOffset} stopColor="red" stopOpacity={1} />
<stop offset={gradientOffset} stopColor="green" stopOpacity={1} />
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="need"
unit="min"
stroke="#000"
fill={`url(#${gradientId})`}
/>
{getPoints(data)}
</AreaChart>
</ResponsiveContainer>
<div className="relative h-full min-h-[260px]">
<div className="absolute right-2 top-0 z-10 flex items-center gap-4 text-[10px] font-medium text-slate-500">
<span className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-cyan-500" /> 생산 우위
</span>
<span className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full bg-orange-500" /> 소비 초과
</span>
<span className="flex items-center gap-1.5">
<span className="h-px w-4 border-t border-dashed border-slate-500" />
균형선
</span>
</div>
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={data}
margin={{
top: 34,
right: 18,
left: -8,
bottom: 4,
}}
>
<CartesianGrid
vertical={false}
stroke="#e2e8f0"
strokeDasharray="3 5"
/>
<XAxis
dataKey="offset"
type="number"
tick={{ fontSize: 10, fill: '#64748b' }}
tickFormatter={minutesToTimeString}
tickLine={false}
axisLine={{ stroke: '#cbd5e1' }}
tickMargin={10}
minTickGap={42}
domain={[minXAxisDomain, maxXAxisDomain]}
/>
<YAxis
tick={{ fontSize: 10, fill: '#64748b' }}
domain={yAxisConfig.domain}
ticks={yAxisConfig.ticks}
tickFormatter={(value) => `${value}m`}
tickLine={false}
axisLine={false}
tickMargin={8}
width={42}
/>
<Tooltip
cursor={{ stroke: '#93c5fd', strokeDasharray: '3 3' }}
contentStyle={{
border: '1px solid #dbeafe',
borderRadius: 10,
boxShadow: '0 12px 30px rgba(15, 23, 42, 0.12)',
fontSize: 12,
}}
labelFormatter={(label) =>
`시각 ${minutesToTimeString(Number(label))}`
}
formatter={(value: number | string) => {
const minutes = Number(value);
return [
`${Math.abs(minutes)}분`,
minutes > 0 ? '소비 초과' : '생산 우위',
];
}}
/>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop
offset={gradientOffset}
stopColor="#f97316"
stopOpacity={0.3}
/>
<stop
offset={gradientOffset}
stopColor="#06b6d4"
stopOpacity={0.24}
/>
</linearGradient>
</defs>
<ReferenceLine
y={0}
stroke="#64748b"
strokeDasharray="5 5"
strokeWidth={1.25}
/>
<Area
type="monotone"
dataKey="need"
name="시간 밸런스"
unit="분"
stroke="#2563eb"
strokeWidth={2.5}
fill={`url(#${gradientId})`}
activeDot={{
r: 4,
fill: '#2563eb',
stroke: '#ffffff',
strokeWidth: 2,
}}
/>
{getPoints(data)}
</AreaChart>
</ResponsiveContainer>
</div>
);
};
16 changes: 8 additions & 8 deletions apps/web/src/components/charts/AvailableRestTimeChart/points.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ReferenceDot
key="high"
Expand All @@ -26,15 +26,15 @@ export const getPoints = (data: ChartDataPoint[]) => {
value={formatMinutesWithSign(highPoint.need)}
position="top"
fill={color}
fontSize={14}
fontSize={11}
fontWeight="bold"
/>
</ReferenceDot>,
);
}

if (lowPoint) {
const color = lowPoint.need >= 0 ? 'red' : 'green';
const color = lowPoint.need >= 0 ? '#f97316' : '#06b6d4';
points.push(
<ReferenceDot
key="low"
Expand All @@ -49,7 +49,7 @@ export const getPoints = (data: ChartDataPoint[]) => {
value={formatMinutesWithSign(lowPoint.need)}
position="bottom"
fill={color}
fontSize={14}
fontSize={11}
fontWeight="bold"
/>
</ReferenceDot>,
Expand All @@ -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"
/>
</ReferenceDot>,
Expand Down Expand Up @@ -117,7 +117,7 @@ function isNearPoint(
type CurrentPointConfig = {
point: ChartDataPoint | null;
shouldShow: boolean;
color: 'red' | 'green';
color: '#f97316' | '#06b6d4';
position: 'top' | 'bottom';
};

Expand All @@ -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 };
Expand Down
Loading
Loading