Skip to content
Merged
4 changes: 2 additions & 2 deletions .electron-builder.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ const config = {
/* 알림에 추가한 이미지 빌드 시 */
extraResources: [
{
from: 'src/main/assets/Symbol Logo.png',
to: 'Symbol Logo.png',
from: 'src/main/assets/Symbol_Logo.png',
to: 'Symbol_Logo.png',
},
],
asar: true,
Expand Down
34 changes: 34 additions & 0 deletions src/renderer/src/hooks/useAutoMetricsSender.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useEffect } from 'react';
import { MetricData } from '../types/main/session';

/**
* 5분마다 자동으로 메트릭 데이터를 전송하는 훅
*
* @param metricsRef - 메트릭 데이터를 담고 있는 ref
* @param sendMetrics - 메트릭 전송 함수
*/
export const useAutoMetricsSender = (
metricsRef: React.RefObject<MetricData[]>,
sendMetrics: () => void,
) => {
useEffect(() => {
const FIVE_MINUTES = 5 * 60 * 1000; // 5분 = 300,000ms

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

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

// 클린업: 컴포넌트 언마운트 시 interval 정리
return () => {
clearInterval(intervalId);
};
}, [metricsRef, sendMetrics]);
};
82 changes: 82 additions & 0 deletions src/renderer/src/hooks/useSessionCleanup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { useEffect } from 'react';
import { useSaveMetricsMutation } from '../api/session/useSaveMetricsMutation';
import { useStopSessionMutation } from '../api/session/useStopSessionMutation';
import { MetricData } from '../types/main/session';

/**
* 창 닫기 시 세션 정리 훅
*
* 동작:
* 1. 남은 메트릭을 서버로 전송 시도 (데이터 저장장)
* 2. 활성 세션 종료 API 호출 시도
* 3. sessionId를 lastSessionId로 백업 (리포트 조회용)
* 4. sessionId 삭제 (재진입 시 새로운 세션 생성)
* 5. 카메라 상태를 exit로 변경 (재진입 시 오늘의 리포트 표시)
* 6. 위젯 열려 있으면 닫기
*
* 주의: beforeunload는 비동기 작업 완료를 보장하지 않으므로,
* API 호출은 시도만 하고, localStorage 정리는 동기적으로 처리
*/

export const useSessionCleanup = (
metricsRef: React.RefObject<MetricData[]>,
setExit: () => void,
) => {
const { mutate: saveMetrics } = useSaveMetricsMutation();
const { mutate: stopSession } = useStopSessionMutation();

useEffect(() => {
const handleBeforeUnload = () => {
const sessionId = localStorage.getItem('sessionId');

/* 세션이 활성화되어 있는 경우에만 서버와 통신 */
if (sessionId) {
/* 1. 남은 메트릭 전송 시도 (비동기, 완료 보장 안됨) */
if (metricsRef.current && metricsRef.current.length > 0) {
try {
saveMetrics({
sessionId,
metrics: metricsRef.current,
});
} catch (error) {
console.error('Failed to save metrics on cleanup:', error);
}
}

/* 2. 세션 종료 API 호출 시도 (비동기, 완료 보장 안됨) */
try {
stopSession(sessionId);
} catch (error) {
console.error('Failed to stop session on cleanup:', error);
}

/* 3. sessionId를 lastSessionId로 백업 (동기 작업, 확실히 실행됨)
useStopSessionMutation의 onSuccess가 실행 안될 수 있으므로 여기서도 처리 */
localStorage.setItem('lastSessionId', sessionId);

/* 4. sessionId 삭제 (동기 작업, 확실히 실행됨) */
localStorage.removeItem('sessionId');
}

/* 5. 카메라 상태를 exit로 변경 (동기 작업, 확실히 실행됨)
세션 유무와 관계없이 항상 실행하여 재진입 시 오늘의 리포트 표시 */
setExit();

/* 6. 위젯 닫기 (동기 작업, 확실히 실행됨)
세션 유무와 관계없이 항상 실행 */
if (window.electronAPI?.widget?.close) {
try {
window.electronAPI.widget.close();
} catch (error) {
console.error('Failed to close widget:', error);
}
}
};

window.addEventListener('beforeunload', handleBeforeUnload);

return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [saveMetrics, stopSession, metricsRef, setExit]);
};
48 changes: 39 additions & 9 deletions src/renderer/src/pages/Login/components/Loginforrm.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useForm } from 'react-hook-form';
import { useEffect } from 'react';
import TextInput from '../../../components/InputField/TextField';
import SaveIdIcon from '../../../assets/auth/saveid_icon.svg?react';
import LoginButton from './LoginButton';
Expand All @@ -13,11 +14,14 @@ interface LoginFormData {
saveId: boolean;
}

const SAVED_EMAIL_KEY = 'savedEmail';

const LoginForm = () => {
const {
register,
handleSubmit,
watch,
setValue,
formState: { isValid },
} = useForm<LoginFormData>({
mode: 'onChange',
Expand All @@ -28,22 +32,47 @@ const LoginForm = () => {
},
});

useEffect(() => {
const savedEmail = localStorage.getItem(SAVED_EMAIL_KEY);
if (savedEmail) {
setValue('email', savedEmail);
setValue('saveId', true);
}
}, [setValue]);

const loginMutation = useLoginMutation();
const navigate = useNavigate();

const onSubmit = (data: LoginFormData) => {
console.log('로그인 시도:', data);
loginMutation.mutate({
email: data.email,
password: data.password,
});
};

/* @react-refresh-ignore */
const email = watch('email');
/* @react-refresh-ignore */
const password = watch('password');

const handleSaveIdChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue('saveId', e.target.checked);
};

const onSubmit = (data: LoginFormData) => {
console.log('로그인 시도:', data);

loginMutation.mutate(
{
email: data.email,
password: data.password,
},
{
onSuccess: () => {
/* 로그인 성공 시 아이디 저장 처리 */
if (data.saveId) {
localStorage.setItem(SAVED_EMAIL_KEY, data.email);
} else {
localStorage.removeItem(SAVED_EMAIL_KEY);
}
},
},
);
};

return (
<>
<form
Expand Down Expand Up @@ -72,7 +101,8 @@ const LoginForm = () => {
<label className="hbp:gap-2.5 flex cursor-pointer items-center gap-2">
<input
type="checkbox"
{...register('saveId')}
checked={watch('saveId')}
onChange={handleSaveIdChange}
className="sr-only"
/>
<SaveIdIcon
Expand Down
26 changes: 17 additions & 9 deletions src/renderer/src/pages/Main/MainPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,14 @@ import { ModalPortal } from '@ui/Modal/ModalPortal';
import AverageGraphPannel from './components/AverageGraph/AverageGraphPannel';
import { useModal } from '../../hooks/useModal';
import { useNotificationScheduler } from '../../hooks/useNotificationScheduler';
import { useSessionCleanup } from '../../hooks/useSessionCleanup';
import { useAutoMetricsSender } from '../../hooks/useAutoMetricsSender';

const LOCAL_STORAGE_KEY = 'calibration_result_v1';

const MainPage = () => {
const setStatus = usePostureStore((state) => state.setStatus);
const { cameraState, setHide, setShow } = useCameraStore();
const { cameraState, setHide, setShow, setExit } = useCameraStore();

// 메트릭 저장 mutation
const { mutate: saveMetrics } = useSaveMetricsMutation();
Expand All @@ -41,14 +43,6 @@ const MainPage = () => {

const classifierRef = useRef(new PostureClassifier());

const handleToggleWebcam = () => {
if (cameraState === 'show') {
setHide();
} else {
setShow();
}
};

// 메트릭을 서버로 전송하는 함수
const sendMetricsToServer = () => {
const sessionId = localStorage.getItem('sessionId');
Expand All @@ -62,6 +56,20 @@ const MainPage = () => {
}
};

/* 창 닫기 시 세션 정리 (메트릭 전송, 세션 종료, 카메라 종료, 위젯 닫기) */
useSessionCleanup(metricsRef, setExit);

/* 5분마다 자동으로 메트릭 전송 */
useAutoMetricsSender(metricsRef, sendMetricsToServer);

const handleToggleWebcam = () => {
if (cameraState === 'show') {
setHide();
} else {
setShow();
}
};

// 캘리브레이션 로드
const calib = (() => {
try {
Expand Down
27 changes: 12 additions & 15 deletions src/renderer/src/pages/Main/components/AttendacePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,10 @@ const Circle = ({ level, today, future }: CircleProps) => {
return (
<div
className={[
'h-[18px] w-[18px] rounded-full bg-grey-50',
today ? 'ring-2 ring-yellow-500 ring-offset-2 ring-offset-grey-0' : '',
'bg-grey-50 h-[18px] w-[18px] rounded-full',
today
? 'ring-offset-grey-0 ring-2 ring-yellow-500 ring-offset-2'
: '',
].join(' ')}
/>
);
Expand All @@ -56,7 +58,7 @@ const Circle = ({ level, today, future }: CircleProps) => {
className={[
'h-[18px] w-[18px] rounded-full',
colorClass,
today ? 'ring-2 ring-yellow-500 ring-offset-2 ring-offset-grey-0' : '',
today ? 'ring-offset-grey-0 ring-2 ring-yellow-500 ring-offset-2' : '',
].join(' ')}
/>
);
Expand Down Expand Up @@ -148,16 +150,11 @@ const getSubContentMessage = (subContent?: string): string => {
}

const messageMap: Record<string, string> = {
뽀각거부기:
'뚠뚠한 골든리트리버 한 마리를 매일 목에 업고 있어요 🐶',
꾸부정거부기:
'기내용 캐리어를 목 위에 올려두고 앉아 있는 셈이에요 🧳',
아기기린:
'무거운 볼링공을 목에 걸고 일하는 중이에요 🎳',
쑥쑥기린:
'작은 수박 한 통 정도를 목에 얹은 상태예요 🍉',
꽃꼿기린:
'머리 본연의 무게만 딱! 지금 아주 좋아요 🌸',
뽀각거부기: '뚠뚠한 골든리트리버 한 마리를 매일 목에 업고 있어요 🐶',
꾸부정거부기: '기내용 캐리어를 목 위에 올려두고 앉아 있는 셈이에요 🧳',
아기기린: '무거운 볼링공을 목에 걸고 일하는 중이에요 🎳',
쑥쑥기린: '작은 수박 한 통 정도를 목에 얹은 상태예요 🍉',
꽃꼿기린: '머리 본연의 무게만 딱! 지금 아주 좋아요 🌸',
};

return messageMap[subContent] || subContent;
Expand Down Expand Up @@ -225,7 +222,7 @@ const AttendacePanel = () => {
uncheckedLabel="월간"
checkedLabel="연간"
checked={false}
onChange={() => { }}
onChange={() => {}}
/>
<IntensitySlider leftLabel="Less" rightLabel="More" />
</div>
Expand All @@ -243,7 +240,7 @@ const AttendacePanel = () => {
<div className="text-grey-700 text-body-md-semibold">
{attendanceData?.data.title || '잘하고 있어요!'}
</div>
<div className="text-caption-2xs-regular text-grey-600 flex flex-col gap-1">
<div className="text-caption-xs-regular text-grey-600 flex flex-col gap-1">
{attendanceData?.data.content1 && (
<div className="flex items-center gap-1">
<UpIcon />
Expand Down
19 changes: 17 additions & 2 deletions src/renderer/src/pages/Main/components/ExitPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { Cell, Pie, PieChart, ResponsiveContainer } from 'recharts';
import { useSessionReportQuery } from '../../../api/session/useSessionReportQuery';
import { useSessionReportQuery } from '@api/session/useSessionReportQuery';
import { useLevelQuery } from '@api/dashboard/useLevelQuery';

const ExitPanel = () => {
const [sessionId, setSessionId] = useState<string | null>(null);
Expand All @@ -19,6 +20,9 @@ const ExitPanel = () => {
// 세션 리포트 조회
const { data, isLoading, error } = useSessionReportQuery(sessionId);

// 현재 이동거리 조회
const { data: levelData } = useLevelQuery();

// 다크모드 상태 (간단한 방법)
const [isDark, setIsDark] = useState(() =>
document.documentElement.classList.contains('dark'),
Expand Down Expand Up @@ -51,6 +55,17 @@ const ExitPanel = () => {
totalTime > 0 ? Math.round((correctPostureTime / totalTime) * 100) : 75;
const score = data?.data.score || 0; // 바른 자세 점수

/* 이번 세션에서 이동한 거리 계산 */
const currentDistance = levelData?.data.current || 0;
const startDistance = parseInt(
localStorage.getItem('sessionStartDistance') || '0',
10,
);
const sessionDistance = Math.max(
0,
currentDistance - startDistance,
); /* 오늘 이동거리 */

// CSS 변수에서 색상 가져오기 (다크모드 변경 시 재계산)
const colors = useMemo(
() => ({
Expand Down Expand Up @@ -132,7 +147,7 @@ const ExitPanel = () => {
오늘의 리포트
</h2>
<p className="text-headline-3xl-semibold text-grey-700">
뽀각거부기 2cm 성장
오늘 총 {sessionDistance.toLocaleString()}m 이동했어요
</p>
</div>

Expand Down
Loading