Skip to content
4 changes: 2 additions & 2 deletions src/main/src/widgetConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ export const WIDGET_CONFIG = {
defaultHeight: 320,

// 최소 / 최대 크기 (사용자가 조절 가능한 범위)
minWidth: 150,
minHeight: 32,
minWidth: 160,
minHeight: 45,
maxWidth: 260,
maxHeight: 348,

Expand Down
6 changes: 3 additions & 3 deletions src/renderer/src/components/WidgetTitleBar/WidgetTitleBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ export function WidgetTitleBar({
<div
className={`bg-grey-0 flex ${
isMini
? 'mr-1 h-full w-2 flex-col items-center justify-center'
: 'mb-1 h-2 w-full justify-center'
? 'h-full w-[14px] flex-col items-center justify-center pr-1'
: 'mt-[-1px] h-5 w-full items-center justify-center pb-1'
} `}
style={{
// 드래그 가능하게 설정 (Electron에서 창 이동 가능)
Expand All @@ -52,7 +52,7 @@ export function WidgetTitleBar({
{/* 빨간 닫기 버튼 */}
<button
onClick={handleClose}
className="h-2 w-2 rounded-full bg-[#FF5154] hover:bg-red-600"
className="mini:mt-[2px] h-[10px] w-[10px] rounded-full bg-[#FF5154] hover:bg-red-600"
style={{
// 버튼은 클릭 가능하도록 드래그 비활성화
// @ts-expect-error: electronAPI 타입 정의 없음
Expand Down
58 changes: 52 additions & 6 deletions src/renderer/src/hooks/useAutoMetricsSender.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useEffect } from 'react';
import { useEffect, useRef, useState } from 'react';
import { MetricData } from '../types/main/session';

/**
* 5분마다 자동으로 메트릭 데이터를 전송하는 훅
* 세션이 종료되면 타이머를 초기화합니다.
*
* @param metricsRef - 메트릭 데이터를 담고 있는 ref
* @param sendMetrics - 메트릭 전송 함수
Expand All @@ -11,24 +12,69 @@ export const useAutoMetricsSender = (
metricsRef: React.RefObject<MetricData[]>,
sendMetrics: () => void,
) => {
// sessionId를 state로 관리하여 변경 감지
const [sessionId, setSessionId] = useState<string | null>(() =>
localStorage.getItem('sessionId'),
);

// sendMetrics 함수를 ref로 저장 (dependency 문제 방지)
const sendMetricsRef = useRef(sendMetrics);

// sendMetrics가 변경되면 ref 업데이트
useEffect(() => {
sendMetricsRef.current = sendMetrics;
}, [sendMetrics]);

// sessionId 변경 감지 (1초마다 체크)
useEffect(() => {
const checkSessionId = () => {
const currentSessionId = localStorage.getItem('sessionId');
if (currentSessionId !== sessionId) {
console.log(
`[세션 변경 감지] 타이머 초기화 - 이전: ${sessionId}, 현재: ${currentSessionId}`,
);
setSessionId(currentSessionId);
}
};

const checkInterval = setInterval(checkSessionId, 1000);

return () => {
clearInterval(checkInterval);
};
}, [sessionId]);

// 5분마다 자동 전송 (sessionId가 변경되면 타이머 재시작)
useEffect(() => {
// 세션이 없으면 interval을 시작하지 않음
if (!sessionId) {
console.log('[자동 전송] 세션이 없어 타이머를 시작하지 않습니다.');
return;
}

console.log('[자동 전송] 5분 타이머 시작');
const FIVE_MINUTES = 5 * 60 * 1000; // 5분 = 300,000ms

const intervalId = setInterval(() => {
const sessionId = localStorage.getItem('sessionId');
const currentSessionId = localStorage.getItem('sessionId');

// 세션이 활성화되어 있고, 전송할 데이터가 있을 때만 전송
if (sessionId && metricsRef.current && metricsRef.current.length > 0) {
if (
currentSessionId &&
metricsRef.current &&
metricsRef.current.length > 0
) {
console.log(
`[자동 전송] ${metricsRef.current.length}개 메트릭 데이터 전송`,
);
sendMetrics();
sendMetricsRef.current(); // ref를 통해 최신 함수 호출
}
}, FIVE_MINUTES);

// 클린업: 컴포넌트 언마운트 시 interval 정리
// 클린업: 컴포넌트 언마운트 시 또는 세션 변경 시 interval 정리
return () => {
console.log('[자동 전송] 타이머 정리');
clearInterval(intervalId);
};
}, [metricsRef, sendMetrics]);
}, [sessionId, metricsRef]); // sendMetrics 제거!
};
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,17 @@ const WelcomePanel = ({
isPoseDetected,
onStartMeasurement,
}: WelcomePanelProps) => {
// localStorage에서 사용자 이름 가져오기
const username = localStorage.getItem('userName') || '사용자';

return (
<div className="flex w-[422px] min-w-[422px] shrink-0 flex-col pt-12">
<div className="mb-12">
<h1 className="text-title-4xl-bold text-grey-900 mb-[20px]">
바른자세 기준점 등록
</h1>
<p className="text-body-xl-medium text-grey-500 leading-relaxed">
거부기온앤온님의 바른 자세를 등록할 준비가 되셨다면
{username}님의 바른 자세를 등록할 준비가 되셨다면
<br />
측정하기 버튼을 눌러주세요.
</p>
Expand Down
39 changes: 28 additions & 11 deletions src/renderer/src/pages/Main/MainPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,34 @@ const MainPage = () => {

const classifierRef = useRef(new PostureClassifier());

// 메트릭을 서버로 전송하는 함수
const sendMetricsToServer = () => {
const sessionId = localStorage.getItem('sessionId');
if (sessionId && metricsRef.current.length > 0) {
saveMetrics({
sessionId,
metrics: metricsRef.current,
});
// 전송 후 메트릭 초기화
metricsRef.current = [];
}
// 메트릭을 서버로 전송하는 함수 (Promise 반환)
const sendMetricsToServer = (): Promise<void> => {
return new Promise((resolve) => {
const sessionId = localStorage.getItem('sessionId');
if (sessionId && metricsRef.current.length > 0) {
saveMetrics(
{
sessionId,
metrics: metricsRef.current,
},
{
onSuccess: () => {
// 전송 완료 후 메트릭 초기화
metricsRef.current = [];
resolve();
},
onError: () => {
// 에러가 발생해도 resolve (계속 진행)
metricsRef.current = [];
resolve();
},
},
);
} else {
// 전송할 데이터가 없으면 즉시 완료
resolve();
}
});
};

/* 창 닫기 시 세션 정리 (메트릭 전송, 세션 종료, 카메라 종료, 위젯 닫기) */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,10 @@ const AverageGraphPannel = () => {
<Tooltip
position={{ y: 20 }}
contentStyle={{
backgroundColor: '#fff',
border: '1px solid #e5e5e5',
backgroundColor: 'var(--color-surface-modal)',
border: '1px solid var(--color-dashboard-score)',
borderRadius: '8px',
padding: '8px 12px',
padding: '8px 10px',
}}
labelFormatter={() => ''}
itemStyle={{ fontSize: 12 }}
Expand Down
28 changes: 20 additions & 8 deletions src/renderer/src/pages/Main/components/ExitPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,16 @@ const ExitPanel = () => {
};

// 세션 조회 API 데이터 사용
const totalTime = Math.round((data?.data.totalSeconds || 0) / 60); // 초를 분으로 변환
const correctPostureTime = Math.round((data?.data.goodSeconds || 0) / 60); // 바른 자세 시간 (분)
const totalSeconds = data?.data.totalSeconds || 0;
const goodSeconds = data?.data.goodSeconds || 0;

const totalTime = Math.round(totalSeconds / 60); // 초를 분으로 변환
const correctPostureTime = Math.round(goodSeconds / 60); // 바른 자세 시간 (분)

// 비율은 초 단위로 먼저 계산 후 반올림 (정확도 향상)
const correctPosturePercentage =
totalTime > 0 ? Math.round((correctPostureTime / totalTime) * 100) : 75;
totalSeconds > 0 ? Math.round((goodSeconds / totalSeconds) * 100) : 0;

const score = data?.data.score || 0; // 바른 자세 점수

/* 이번 세션에서 이동한 거리 계산 */
Expand Down Expand Up @@ -82,10 +88,16 @@ const ExitPanel = () => {
[colors.background],
);

// 안쪽 링 프로그레스 데이터 (노란색) - 바른 자세 점수만큼 노란색
// 안쪽 링 프로그레스 데이터 (노란색) - 바른 자세 비율만큼 노란색
const ScoreProgressData = useMemo(
() => [{ name: '바른 자세 점수', value: score, color: colors.score }],
[score, colors.score],
() => [
{
name: '바른 자세 비율',
value: correctPosturePercentage,
color: colors.score,
},
],
[correctPosturePercentage, colors.score],
);

const formatTime = (minutes: number) => {
Expand Down Expand Up @@ -179,7 +191,7 @@ const ExitPanel = () => {
innerRadius={77.75}
outerRadius={92}
startAngle={450}
endAngle={450 - (72 / 100) * 360}
endAngle={450 - (correctPosturePercentage / 100) * 360}
dataKey="value"
stroke="none"
paddingAngle={0}
Expand Down Expand Up @@ -211,7 +223,7 @@ const ExitPanel = () => {
바른 자세 시간
</span>
<span className="text-headline-2xl-semibold text-grey-600">
{72}%
{correctPosturePercentage}%
</span>
</p>
</div>
Expand Down
12 changes: 6 additions & 6 deletions src/renderer/src/pages/Main/components/WebcamPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ interface Props {
worldLandmarks?: WorldLandmark[],
) => void;
onToggleWebcam: () => void;
onSendMetrics: () => void;
onSendMetrics: () => Promise<void>;
}

const WebcamPanel = ({
Expand All @@ -45,7 +45,7 @@ const WebcamPanel = ({
useResumeSessionMutation();
const { data: levelData } = useLevelQuery();

const handleStartStop = () => {
const handleStartStop = async () => {
if (isExit) {
// 시작하기: 세션 생성 후 카메라 시작
createSession(undefined, {
Expand All @@ -61,13 +61,13 @@ const WebcamPanel = ({
},
});
} else {
// 종료하기: 메트릭 전송 → 세션 중단 → 카메라 종료 → 위젯 닫기
// 종료하기: 메트릭 전송 완료 → 세션 중단 → 카메라 종료 → 위젯 닫기
const sessionId = localStorage.getItem('sessionId');
if (sessionId) {
// 1. 수집된 메트릭을 서버로 전송
onSendMetrics();
// 1. 수집된 메트릭을 서버로 전송 (완료 대기)
await onSendMetrics();

// 2. 세션 종료
// 2. 세션 종료 (메트릭 전송 완료 후 실행)
stopSession(sessionId, {
onSuccess: () => {
setExit();
Expand Down
3 changes: 1 addition & 2 deletions src/renderer/src/pages/Widget/WidgetPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { usePostureSyncWithLocalStorage } from './hooks/usePostureSyncWithLocalS
import { useThemeSync } from './hooks/useThemeSync';

type WidgetSize = 'mini' | 'medium';
type PostureState = 'turtle' | 'giraffe';

/* 레이아웃 전환 기준점 */
const BREAKPOINT = {
Expand Down Expand Up @@ -75,7 +74,7 @@ export function WidgetPage() {
const isMini = widgetSize === 'mini';

return (
<div className="bg-grey-0 h-screen w-screen overflow-hidden rounded-lg px-1 py-[5px]">
<div className="bg-grey-0 h-screen w-screen overflow-hidden rounded-lg px-[4px] py-[3px]">
<div className={isMini ? 'flex h-full w-full' : 'h-full w-full'}>
{/* 커스텀 타이틀바 */}
<WidgetTitleBar isMini={isMini} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,10 @@ export function MediumWidgetContent({ posture }: MediumWidgetContentProps) {
}

return (
<div className="flex h-full w-full flex-col transition-colors duration-500 ease-in-out">
<div className="flex h-full w-full flex-col pb-[20px] transition-colors duration-500 ease-in-out">
{/* 캐릭터 영역 */}
<div
className="mini:h-auto mini: mb-[3px] flex aspect-[1/1] h-full w-full flex-1 rounded-lg transition-all duration-500 ease-in-out"
className="mini:h-auto flex aspect-[1/1] h-full max-h-[235px] w-full flex-1 rounded-lg transition-all duration-500 ease-in-out"
style={{ background: gradient }}
>
{isGiraffe ? (
Expand All @@ -78,7 +78,7 @@ export function MediumWidgetContent({ posture }: MediumWidgetContentProps) {
</div>

{/* 상세 정보 영역 */}
<div className="bg-grey-0 flex w-full flex-1 flex-col justify-center px-2">
<div className="bg-grey-0 mt-1 flex w-full flex-1 flex-col justify-center px-2">
{/* 진행 바 */}
<div className="h-fit w-full rounded-lg">
<div className="bg-grey-50 h-3 w-full rounded-full">
Expand Down
1 change: 1 addition & 0 deletions src/renderer/src/styles/breakpoint.css
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
}

/* 미디어 쿼리 헬퍼 */
@custom-variant mini (@media(max-height: 62px));
@custom-media --mobile (max-width: 30rem);
@custom-media --tablet (min-width: 30rem) and (max-width: 56rem);
@custom-media --large (min-width: 80rem);
Expand Down
9 changes: 9 additions & 0 deletions src/renderer/src/styles/colors.css
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@
--color-modal-button: #f9f8f7;
--color-modal-disabled: #e3e1df;
--color-global-yellow-100: #ffebb0;

/* 평균 자세 그래프 색깔 */
--color-dashboard-score: #ffe28a;
}

@theme inline {
Expand Down Expand Up @@ -166,6 +169,9 @@
--color-modal-disabled: var(--color-modal-disabled);
--color-sementic-brand-primary: var(--color-sementic-brand-primary);
--color-global-yellow-100: var(--color-global-yellow-100);

/* 평균 자세 그래프 색깔*/
--color-dashboard-score: var(--color-dashboard-score);
}

/* Dark mode colors */
Expand Down Expand Up @@ -246,4 +252,7 @@
--color-modal-button: #232323;
--color-modal-disabled: #2c2c2c;
--color-global-yellow-100: rgba(73, 55, 4, 0.5);

/* 평균 자세 그래프 색깔*/
--color-dashboard-score: #2c2c2c;
}