/* =================================== * 파일명: challenge-script.js * 위치: /js/challenge-script.js * 기능: 챌린지 체크리스트 애플리케이션 전체 JavaScript 로직 * 작성일: 2025-02-28 * 수정일: 2025-02-28 * =================================== */ /* =================================== * 전역 변수 및 초기화 * - 애플리케이션 전반에 걸쳐 사용되는 전역 변수 선언 * - 페이지네이션, 현재 챌린지 상태, 모달 객체 등 정의 * =================================== */ // 페이지당 표시할 챌린지 개수 const CHALLENGES_PER_PAGE = 10; // 현재 페이지 번호 let currentPage = 1; // 챌린지 데이터 배열 let challenges = []; // 현재 선택된 챌린지 ID let currentChallengeId = null; // 현재 선택된 챌린지 박스 번호 let currentBoxNumber = null; // 현재 액션 타입 (complete, revert, edit 등) let currentAction = null; // 비밀번호 검증 상태 let currentPasswordVerified = false; // Bootstrap 모달 객체들 - 각 기능별 모달 관리 let createModal, updateModal, completeModal, challengeMenuModal, editChallengeModal; /** * 페이지 로드 시 실행되는 초기화 함수 * - 모든 모달 초기화 * - 챌린지 데이터 로드 * - 이벤트 리스너 등록 */ $(document).ready(function() { // Bootstrap 모달 초기화 - 모달 외부 클릭 시 닫히지 않도록 설정 createModal = new bootstrap.Modal(document.getElementById('createModal'), { backdrop: 'static', keyboard: false }); updateModal = new bootstrap.Modal(document.getElementById('updateModal'), { backdrop: 'static', keyboard: false }); completeModal = new bootstrap.Modal(document.getElementById('completeModal'), { backdrop: 'static', keyboard: false }); challengeMenuModal = new bootstrap.Modal(document.getElementById('challengeMenuModal'), { backdrop: 'static', keyboard: false }); editChallengeModal = new bootstrap.Modal(document.getElementById('editChallengeModal'), { backdrop: 'static', keyboard: false }); // 완료 취소 확인 모달 초기화 revertConfirmModal = new bootstrap.Modal(document.getElementById('revertConfirmModal'), { backdrop: 'static', keyboard: false }); // 이용 방법 토글 기능 $('#usageToggle').on('click', function() { $('#usageContent').slideToggle(300); $('.toggle-icon').toggleClass('rotate'); // 로컬 스토리지에 상태 저장 const isVisible = $('#usageContent').is(':visible'); localStorage.setItem('usageGuideVisible', isVisible ? 'true' : 'false'); }); // 시작일 입력 필드에 툴팁 추가 $('#startDate, #editStartDate').tooltip({ title: '챌린지를 시작하는 날짜입니다', placement: 'top', trigger: 'focus' }); // 이전 상태 복원 (사용자가 명시적으로 펼친 경우에만) const usageGuideVisible = localStorage.getItem('usageGuideVisible'); if (usageGuideVisible === 'true') { $('#usageContent').show(); $('.toggle-icon').addClass('rotate'); } else { // 기본값: 접혀 있음 (명시적으로 설정) $('#usageContent').hide(); $('.toggle-icon').removeClass('rotate'); } // 챌린지 데이터 로드 loadChallenges(); // 이벤트 리스너 등록 addEventListeners(); }); /* =================================== * 데이터 로드 및 저장 기능 * - 서버와 데이터 통신 * - AJAX를 통한 챌린지 데이터 관리 * =================================== */ /** * 서버에서 챌린지 불러오기 * - API 엔드포인트에서 챌린지 데이터 GET 요청 * - 성공 시 challenges 배열 업데이트 및 UI 렌더링 * - 실패 시 빈 배열로 초기화 * - ID 형식 일관성 보장을 위한 처리 추가 */ function loadChallenges() { $.ajax({ url: 'api/challenges.php', type: 'GET', dataType: 'json', success: function(data) { console.log('Loaded Challenges:', data); // ID 형식 일관성 보장 (문자열로 변환) challenges = (data || []).map(challenge => { if (challenge.id && typeof challenge.id !== 'string') { challenge.id = String(challenge.id); } return challenge; }); renderChallenges(); renderPagination(); }, error: function(xhr, status, error) { console.error('Error loading challenges:', error); challenges = []; renderChallenges(); renderPagination(); } }); } /** * 챌린지 서버에 저장 * - 현재 challenges 배열 전체를 서버에 POST 요청 * - 성공 시 화면 다시 렌더링 * - 실패 시 에러 알림 * - 콜백 함수 지원 추가 * * @param {Function} callback 저장 성공 후 실행할 콜백 함수 (선택적) */ function saveChallenges(callback) { $.ajax({ url: 'api/challenges.php', type: 'POST', contentType: 'application/json', data: JSON.stringify(challenges), success: function(response) { console.log('Challenges saved successfully'); // 화면 갱신 renderChallenges(); // 콜백 함수가 있으면 실행 if (typeof callback === 'function') { callback(response); } }, error: function(xhr, status, error) { console.error('Error saving challenges:', error); alert('챌린지 저장 중 오류가 발생했습니다.'); } }); } /* =================================== * 비밀번호 관련 유틸리티 함수 * - 보안 및 인증 관련 함수 * =================================== */ /** * 비밀번호 해시 처리 * - 현재는 평문 그대로 반환 * - 향후 보안 강화를 위해 해시 알고리즘 추가 가능 * * @param {string} password 비밀번호 * @return {string} 처리된 비밀번호 */ function hashPassword(password) { return password; } /** * 비밀번호 검증 * - 입력된 비밀번호와 저장된 비밀번호 직접 비교 * * @param {string} password 입력된 비밀번호 * @param {string} storedPassword 저장된 비밀번호 * @return {boolean} 비밀번호 일치 여부 */ function verifyPassword(password, storedPassword) { return password === storedPassword; } /** * 애플리케이션의 모든 이벤트 리스너 등록 * - 동적 요소에 대한 이벤트 처리 개선 * - ID 형식 일관성 보장 */ function addEventListeners() { // 챌린지 생성 버튼 $('#createChallengeBtn').on('click', function() { $('#startDate').val(new Date().toISOString().slice(0, 10)); createModal.show(); }); // 챌린지 생성 폼 제출 $('#createChallengeForm').on('submit', handleCreateChallenge); // 비밀번호 폼 제출 $('#passwordForm').on('submit', handlePasswordCheck); // 챌린지 관리 버튼 이벤트 $('#editChallengeInfoBtn').on('click', function() { loadChallengeForEdit(); challengeMenuModal.hide(); editChallengeModal.show(); }); // 완료 취소 확인 버튼 $('#revertConfirmBtn').on('click', handleRevertConfirm); // 완료 취소 취소 버튼 $('#revertCancelBtn').on('click', handleRevertCancel); // 챌린지 삭제 버튼 이벤트 $('#deleteChallengeBtn').on('click', function() { if (confirm('정말로 이 챌린지를 삭제하시겠습니까?')) { deleteChallenge(); challengeMenuModal.hide(); } }); // 챌린지 수정 폼 제출 $('#editChallengeForm').on('submit', handleEditChallenge); // 완료 확인 $('#completeYesBtn').on('click', handleChallengeComplete); // 모달 닫기 버튼들 $('#completeNoBtn').on('click', function() { completeModal.hide(); }); // 모달 닫기 버튼 이벤트 $('.btn-close').on('click', function() { const modal = $(this).closest('.modal'); const modalId = modal.attr('id'); if (modalId === 'createModal') { createModal.hide(); } else if (modalId === 'updateModal') { updateModal.hide(); } else if (modalId === 'completeModal') { completeModal.hide(); } else if (modalId === 'challengeMenuModal') { challengeMenuModal.hide(); } else if (modalId === 'editChallengeModal') { editChallengeModal.hide(); } }); // 동적으로 생성되는 요소에 대한 이벤트 위임 $(document).on('click', '.edit-challenge-btn', function() { // ID를 문자열로 확실히 저장 currentChallengeId = String($(this).data('challenge')); console.log('관리 버튼 클릭 - 설정된 챌린지 ID:', currentChallengeId, '타입:', typeof currentChallengeId); currentAction = 'edit'; currentPasswordVerified = false; // 폼 초기화 $('#checkPassword').val(''); updateModal.show(); }); $(document).on('click', '.complete-btn', function() { // ID를 문자열로 확실히 저장 currentChallengeId = String($(this).data('challenge')); console.log('완료 버튼 클릭 - 설정된 챌린지 ID:', currentChallengeId, '타입:', typeof currentChallengeId); currentBoxNumber = parseInt($(this).data('box')); currentAction = 'complete'; // 폼 초기화 $('#checkPassword').val(''); updateModal.show(); }); $(document).on('click', '.revert-btn', function() { // ID를 문자열로 확실히 저장 currentChallengeId = String($(this).data('challenge')); console.log('취소 버튼 클릭 - 설정된 챌린지 ID:', currentChallengeId, '타입:', typeof currentChallengeId); currentBoxNumber = parseInt($(this).data('box')); currentAction = 'revert'; // 폼 초기화 $('#checkPassword').val(''); updateModal.show(); }); // 창 크기 변경 시 다시 렌더링 window.addEventListener('resize', function() { renderChallenges(); }); } /** * 챌린지 생성 핸들러 * - 폼 데이터로 새로운 챌린지 객체 생성 * - 챌린지 배열에 추가 및 서버 저장 * - 생성 후 즉시 화면에 표시되도록 개선 * * @param {Event} e 이벤트 객체 */ function handleCreateChallenge(e) { e.preventDefault(); const participantName = $('#participantName').val(); const goal = $('#goal').val(); const quantity = parseInt($('#quantity').val()); const startDate = $('#startDate').val(); const password = $('#password').val().trim(); // 공백 제거 if (quantity < 1 || quantity > 100) { alert('수량은 1에서 100 사이여야 합니다.'); return; } const newChallenge = { id: String(Date.now()), // 문자열로 확실히 저장 participantName, goal, quantity, startDate, password: password, // 공백 제거된 비밀번호 저장 boxes: Array(quantity).fill().map((_, i) => ({ number: i + 1, completed: false, completionDate: null })), createdAt: new Date().toISOString() }; // 새 챌린지를 배열 맨 앞에 추가 challenges.unshift(newChallenge); // 챌린지 저장 후 콜백에서 화면 갱신 $.ajax({ url: 'api/challenges.php', type: 'POST', contentType: 'application/json', data: JSON.stringify(challenges), success: function(response) { console.log('Challenges saved successfully'); // 모달 닫고 폼 초기화 createModal.hide(); $('#createChallengeForm')[0].reset(); // 페이지 1로 이동하고 화면 갱신 currentPage = 1; renderChallenges(); renderPagination(); // 성공 메시지 표시 showToast('챌린지가 성공적으로 생성되었습니다.', 'success'); }, error: function(xhr, status, error) { console.error('Error saving challenges:', error); alert('챌린지 저장 중 오류가 발생했습니다.'); } }); } /** * 알림 메시지 표시 함수 * - 사용자에게 작업 결과를 알림 * - 자동으로 사라지는 토스트 메시지 * * @param {string} message 표시할 메시지 * @param {string} type 메시지 타입 (success, error, info) */ function showToast(message, type = 'info') { // 기존 토스트가 있으면 제거 $('.toast-container').remove(); // 타입에 따른 클래스와 아이콘 설정 let bgClass = 'bg-info'; let icon = 'bi-info-circle'; if (type === 'success') { bgClass = 'bg-success'; icon = 'bi-check-circle'; } else if (type === 'error') { bgClass = 'bg-danger'; icon = 'bi-exclamation-circle'; } // 토스트 HTML 생성 const toastHtml = `
`; // 토스트 추가 및 자동 제거 타이머 설정 $('body').append(toastHtml); setTimeout(() => { $('.toast-container').fadeOut('slow', function() { $(this).remove(); }); }, 3000); } /** * 비밀번호 체크 핸들러 * - 완료 취소 시 확인 모달 추가 * * @param {Event} e 이벤트 객체 */ function handlePasswordCheck(e) { e.preventDefault(); // 현재 선택된 ID를 문자열로 확실히 변환 if (currentChallengeId !== null && typeof currentChallengeId !== 'string') { currentChallengeId = String(currentChallengeId); } const passwordInput = $('#checkPassword').val().trim(); // 디버깅을 위한 상세 로그 console.log('현재 선택된 챌린지 ID (문자열 변환 후):', currentChallengeId); console.log('모든 챌린지 ID 목록:', challenges.map(c => c.id)); console.log('모든 챌린지 ID 타입:', challenges.map(c => typeof c.id)); // 엄격한 문자열 비교로 챌린지 찾기 const challenge = challenges.find(c => String(c.id) === String(currentChallengeId)); if (!challenge) { console.error('선택된 챌린지를 찾을 수 없습니다. ID:', currentChallengeId); // 모든 챌린지 출력하여 디버깅 console.log('전체 챌린지 목록:', JSON.stringify(challenges, null, 2)); alert('챌린지 정보를 찾을 수 없습니다. 페이지를 새로고침 후 다시 시도해주세요.'); updateModal.hide(); return; } console.log('찾은 챌린지:', challenge); console.log('입력된 비밀번호:', passwordInput); console.log('저장된 비밀번호:', challenge.password); // 비밀번호 비교 (양쪽 공백 제거 후) const storedPassword = (challenge.password || '').trim(); if (passwordInput === storedPassword) { updateModal.hide(); $('#checkPassword').val(''); currentPasswordVerified = true; if (currentAction === 'edit') { challengeMenuModal.show(); } else if (currentAction === 'complete') { completeModal.show(); } else if (currentAction === 'revert') { // 완료 취소 확인 모달 표시 $('#revertConfirmModalTitle').text('완료 취소'); $('#revertConfirmModalBody').text('완료를 취소하시겠습니까?'); revertConfirmModal.show(); } } else { alert('비밀번호가 일치하지 않습니다.'); } } /** * 완료 취소 확인 핸들러 * - 완료 취소 확인 후 처리 */ function handleRevertConfirm() { revertChallengeBox(); revertConfirmModal.hide(); } /** * 완료 취소 취소 핸들러 * - 완료 취소 취소 처리 */ function handleRevertCancel() { revertConfirmModal.hide(); } /** * 챌린지 완료 핸들러 * - 현재 선택된 챌린지 박스 완료 처리 */ function handleChallengeComplete() { const challenge = challenges.find(c => c.id === currentChallengeId); if (challenge) { const box = challenge.boxes.find(b => b.number === currentBoxNumber); if (box) { box.completed = true; box.completionDate = new Date().toISOString().split('T')[0]; saveChallenges(); completeModal.hide(); renderChallenges(); } } } /** * 챌린지 박스 완료 취소 * - 완료된 챌린지 박스를 미완료 상태로 되돌림 */ function revertChallengeBox() { const challenge = challenges.find(c => c.id === currentChallengeId); if (challenge) { const box = challenge.boxes.find(b => b.number === currentBoxNumber); if (box) { box.completed = false; box.completionDate = null; saveChallenges(); renderChallenges(); } } } /** * 챌린지 정보 수정을 위해 로드 * - ID 형식 일관성 보장 */ function loadChallengeForEdit() { // 현재 선택된 ID를 문자열로 확실히 변환 if (currentChallengeId !== null && typeof currentChallengeId !== 'string') { currentChallengeId = String(currentChallengeId); } // 엄격한 문자열 비교로 챌린지 찾기 const challenge = challenges.find(c => String(c.id) === String(currentChallengeId)); if (challenge) { $('#editParticipantName').val(challenge.participantName); $('#editGoal').val(challenge.goal); $('#editQuantity').val(challenge.quantity); $('#editStartDate').val(challenge.startDate); $('#editPassword').val(''); } else { console.error('수정할 챌린지를 찾을 수 없습니다. ID:', currentChallengeId); } } /** * 챌린지 정보 수정 처리 * - ID 형식 일관성 보장 * - 비밀번호 공백 제거 추가 * * @param {Event} e 이벤트 객체 */ function handleEditChallenge(e) { e.preventDefault(); // 현재 선택된 ID를 문자열로 확실히 변환 if (currentChallengeId !== null && typeof currentChallengeId !== 'string') { currentChallengeId = String(currentChallengeId); } // 엄격한 문자열 비교로 챌린지 찾기 const challenge = challenges.find(c => String(c.id) === String(currentChallengeId)); if (!challenge) { console.error('수정할 챌린지를 찾을 수 없습니다. ID:', currentChallengeId); alert('챌린지 정보를 찾을 수 없습니다. 페이지를 새로고침 후 다시 시도해주세요.'); return; } const newParticipantName = $('#editParticipantName').val(); const newGoal = $('#editGoal').val(); const newQuantity = parseInt($('#editQuantity').val()); const newStartDate = $('#editStartDate').val(); const newPassword = $('#editPassword').val().trim(); // 공백 제거 if (newQuantity < 1 || newQuantity > 100) { alert('수량은 1에서 100 사이여야 합니다.'); return; } // 수량이 변경된 경우 도전 박스 조정 if (newQuantity !== challenge.quantity) { if (newQuantity > challenge.quantity) { // 도전 박스 추가 for (let i = challenge.quantity + 1; i <= newQuantity; i++) { challenge.boxes.push({ number: i, completed: false, completionDate: null }); } } else { // 도전 박스 제거 (완료된 박스는 유지) challenge.boxes = challenge.boxes.filter(box => box.number <= newQuantity || box.completed ); } } // 정보 업데이트 challenge.participantName = newParticipantName; challenge.goal = newGoal; challenge.quantity = newQuantity; challenge.startDate = newStartDate; // 비밀번호가 입력된 경우 업데이트 if (newPassword) { challenge.password = newPassword; } saveChallenges(); editChallengeModal.hide(); renderChallenges(); } /** * 챌린지 삭제 * - 현재 선택된 챌린지를 배열에서 제거 */ function deleteChallenge() { challenges = challenges.filter(c => c.id !== currentChallengeId); saveChallenges(); renderChallenges(); renderPagination(); } /** * 완료된 챌린지 박스의 HTML 템플릿 * - 공간 효율성을 위해 간소화된 디자인 적용 * - 클릭 시 수정 모달 표시 * * @param {Object} box 박스 정보 * @param {string} challengeId 챌린지 ID * @return {string} HTML 템플릿 문자열 */ function getCompletedBoxTemplate(box, challengeId) { // 간소화된 템플릿으로 변경 - 숫자만 표시 return `
`; } /** * 현재 차례 챌린지 박스의 HTML 템플릿 * - 모바일/데스크톱 환경에 따라 다른 템플릿 제공 * * @param {Object} box 박스 정보 * @param {string} challengeId 챌린지 ID * @param {string} startDate 시작일 * @return {string} HTML 템플릿 문자열 */ function getCurrentBoxTemplate(box, challengeId, startDate) { const today = new Date().toISOString().split('T')[0]; const todayDate = new Date(today); const daysDiff = Math.floor((todayDate - new Date(startDate)) / (1000 * 60 * 60 * 24)) + 1; // 모바일 환경 감지 const isMobile = window.innerWidth <= 576; if (isMobile) { // 모바일 환경에서는 간소화된 텍스트로 표시 return `
Today: ${formatDate(today)}
${daysDiff}일차
`; } else { // PC 환경에서는 기존 템플릿 유지 return `
Today: ${formatDate(today)}
챌린지 ${daysDiff}일차
`; } } /** * 챌린지 렌더링 함수 * - 챌린지 목록을 페이지네이션에 맞게 렌더링 * - 챌린지 상태에 따른 UI 처리 (완료, 진행 중 등) * - 숫자 중복 표시 문제 해결 */ function renderChallenges() { $('#challengeList').empty(); const start = (currentPage - 1) * CHALLENGES_PER_PAGE; const end = start + CHALLENGES_PER_PAGE; const paginatedChallenges = challenges.slice(start, end); if (paginatedChallenges.length === 0) { $('#challengeList').html('
아직 생성된 챌린지가 없습니다.
'); return; } paginatedChallenges.forEach((challenge, index) => { const letter = String.fromCharCode(65 + index); const completedBoxes = challenge.boxes.filter(box => box.completed).length; const isCompleted = completedBoxes === challenge.quantity; // 첫번째 미완료 박스 번호 찾기 const firstIncompleteBox = challenge.boxes.find(box => !box.completed)?.number || challenge.quantity + 1; // 수량이 많은 챌린지 감지 (20개 이상) const isLargeQuantity = challenge.quantity >= 20; // 수정: 완료된 챌린지에 completed 클래스 추가, 수량이 많으면 large-quantity 클래스 추가 let challengeHtml = `
${letter}

참여자: ${challenge.participantName}

목표: ${challenge.goal}

수량: ${challenge.quantity}회

시작일: ${formatDate(challenge.startDate)}

${isCompleted ? '완료' : ''}
`; challenge.boxes.forEach(box => { let boxClass = 'box'; let boxContent = ''; if (box.completed) { // 완료된 박스도 compact 스타일 적용 (간소화) boxClass += ' completed compact'; // 수정: 숫자를 boxContent에 포함하지 않고 별도로 표시 boxContent = `
`; } else if (box.number === firstIncompleteBox) { boxClass += ' current'; boxContent = getCurrentBoxTemplate(box, challenge.id, challenge.startDate); } else { if (isLargeQuantity) { // 수량이 많은 경우 미완료 박스는 컴팩트 스타일 적용 boxClass += ' pending compact'; // 수정: 숫자만 표시하도록 변경 boxContent = `
`; } else { // 수량이 적은 경우 기존 스타일 유지 boxClass += ' pending'; boxContent = `
`; } } // 박스 타입에 따라 다른 HTML 구조 사용 if (boxClass.includes('compact')) { // 수정: compact 박스는 숫자를 직접 표시 challengeHtml += `
${box.number}
`; } else { // 현재 박스는 기존 디자인 유지 challengeHtml += `
${box.number} ${boxContent}
`; } }); challengeHtml += `
`; $('#challengeList').append(challengeHtml); }); // 이벤트 리스너 - 완료된 박스 클릭 시 수정 모달 표시 $(document).on('click', '.box.completed.compact', function() { // 수정: 데이터 속성에서 ID와 박스 번호 직접 가져오기 currentChallengeId = String($(this).closest('.challenge-card').find('.edit-challenge-btn').data('challenge')); currentBoxNumber = parseInt($(this).text().trim()); currentAction = 'revert'; updateModal.show(); }); } /** * 페이지네이션 렌더링 * - 총 챌린지 수에 따라 페이지네이션 버튼 생성 */ function renderPagination() { $('#pagination').empty(); const totalPages = Math.ceil(challenges.length / CHALLENGES_PER_PAGE); if (totalPages <= 1) { return; } // 이전 페이지 버튼 $('#pagination').append(`
  • `); // 페이지 번호 버튼 for (let i = 1; i <= totalPages; i++) { $('#pagination').append(`
  • ${i}
  • `); } // 다음 페이지 버튼 $('#pagination').append(`
  • `); // 페이지네이션 클릭 이벤트 $('.page-link').on('click', function(e) { e.preventDefault(); const page = $(this).data('page'); if (page && page !== currentPage && page >= 1 && page <= totalPages) { currentPage = page; renderChallenges(); renderPagination(); } }); } /* =================================== * 유틸리티 함수 * - 날짜 포맷 변환 등 보조 함수 * =================================== */ /** * 날짜 형식 변환 * - ISO 형식의 날짜를 MM/DD 형식으로 변환 * * @param {string} dateString ISO 형식의 날짜 문자열 * @return {string} MM/DD 형식의 날짜 문자열 */ function formatDate(dateString) { const date = new Date(dateString); return `${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`; }