๐ฏ ๋ชฉํ ๋ฌ์ฑ ์งํ ๋ฐ - ์๊ฐ์ ๋๊ธฐ๋ถ์ฌ ํธ๋์ปค
visual-goal-progress-tracker
๋ฌธ์ ์ค๋ช
๐ ๋ชฉํ ๋ฌ์ฑ ์งํ ๋ฐ - ์๊ฐ์ ๋๊ธฐ๋ถ์ฌ ํธ๋์ปค
๋ชฉํ๋ฅผ ์ค์ ํ๊ณ ์งํ ์ํฉ์ ์๊ฐ์ ์ผ๋ก ํ์ธํ ์ ์๋ ์งํ ๋ฐ ํธ๋์ปค์ ๋๋ค. ์ฑ ์ฝ๊ธฐ, ์ด๋ ํ์, ์ ์ถ ๊ธ์ก ๋ฑ ๋ค์ํ ๋ชฉํ๋ฅผ ๋ฑ๋กํ๊ณ ๋ฌ์ฑ๋ฅ ์ ์ค์๊ฐ์ผ๋ก ํ์ธํ์ธ์.
โจ ํต์ฌ ๊ธฐ๋ฅ
- ๋ชฉํ ๋ฑ๋ก: ๋ชฉํ๋ช , ๋ชฉํ ์์น, ํ์ฌ ์งํ ์์น ์ ๋ ฅ
- ์งํ ๋ฐ ์๊ฐํ: ํผ์ผํธ ๊ธฐ๋ฐ ํ๋ก๊ทธ๋ ์ค ๋ฐ๋ก ๋ฌ์ฑ๋ฅ ํ์
- ์์ ๋ณํ: ๋ฌ์ฑ๋ฅ ์ ๋ฐ๋ผ ์งํ ๋ฐ ์์ ์๋ ๋ณ๊ฒฝ (0-30% ๋นจ๊ฐ, 31-70% ์ฃผํฉ, 71-99% ๋ ธ๋, 100% ์ด๋ก)
- ๋ชฉํ ์์ : ์งํ ์์น ์ ๋ฐ์ดํธ (+ ๋ฒํผ์ผ๋ก ๊ฐํธ ์ฆ๊ฐ)
- ๋ชฉํ ์ญ์ : ์๋ฃ๋๊ฑฐ๋ ๋ถํ์ํ ๋ชฉํ ์ ๊ฑฐ
- ๋คํฌ๋ชจ๋ ์๋ ๋์: modern shell์ ๋์์ธ ํ ํฐ ํ์ฉ
๐ ๋ณด๋์ค ๊ธฐ๋ฅ (์ ํ)
- ๋ชฉํ๋ณ ์์ด์ฝ ์ ํ ๊ธฐ๋ฅ (๐์ฑ , ๐ช์ด๋, ๐ฐ์ ์ถ ๋ฑ)
- ๋ชฉํ ๋ฌ์ฑ ์ ์ถํ ์ ๋๋ฉ์ด์ ํจ๊ณผ
- ๋ชฉํ ์นดํ ๊ณ ๋ฆฌ๋ณ ๊ทธ๋ฃนํ (๊ฑด๊ฐ, ์ฌ์ , ํ์ต ๋ฑ)
- ์ผ์ผ ์งํ ๊ธฐ๋ก ์ฐจํธ (๊ฐ๋จํ ๋ง๋ ๊ทธ๋ํ)
๐ ๊ธฐ์ ์คํ
- ๋ฐฑ์๋: PHP (๊ทธ๋๋ณด๋5 SE) + SQLite
- ํ๋ก ํธ์๋: HTML5 + CSS3 (๋์์ธ ํ ํฐ) + Vanilla JavaScript
- ๋ฐ์ดํฐ๋ฒ ์ด์ค: SQLite (๋ชจ๋ ์ ์ฉ DB ํ์ผ)
๐ ๋ฐ์ดํฐ ๊ตฌ์กฐ
goals ํ ์ด๋ธ:
- id: ์๋ ์ฆ๊ฐ ๊ธฐ๋ณธํค
- title: ๋ชฉํ๋ช (์: "๋ ์ 100๊ถ ์ฝ๊ธฐ")
- target: ๋ชฉํ ์์น (์: 100)
- current: ํ์ฌ ์งํ ์์น (์: 37)
- icon: ์์ด์ฝ ์ด๋ชจ์ง (์ ํ, ๊ธฐ๋ณธ๊ฐ ๐ฏ)
- created_at: ์์ฑ ์๊ฐ
- updated_at: ์์ ์๊ฐ
๐จ UI/UX ํน์ง
- ๋ชฉํ ์นด๋ ๋ ์ด์์ (๊ทธ๋ฆฌ๋ ๋ฐฉ์)
- ํฐ ์งํ ๋ฐ์ ํผ์ผํธ ํ์๋ก ํ๋์ ๋ฌ์ฑ๋ฅ ํ์ธ
- +1, +5, +10 ๋ฒํผ์ผ๋ก ๋น ๋ฅธ ์งํ ์ ๋ฐ์ดํธ
- ๋ถ๋๋ฌ์ด ์ ๋๋ฉ์ด์ (์งํ ๋ฐ ์ฆ๊ฐ ์ transition ํจ๊ณผ)
- ๋ฐ์ํ ๋์์ธ (๋ชจ๋ฐ์ผ ์ต์ ํ)
์ ์ฝ ์กฐ๊ฑด
- โข ๊ทธ๋๋ณด๋5 SE ์ค์น๋ณธ ์์์ ์๋ (https://github.com/gnuboard/g5se main ๋ธ๋์น)
- โข ํด๋ ๊ธฐ๋ฐ ์๋ ๋ผ์ฐํ ์ฌ์ฉ (app/modules/visual-goal-progress-tracker/index.php โ /visual-goal-progress-tracker)
- โข modern shell ํ์ ์ ์ฉ (_head.inc.php + m-shell + _nav.inc.php + m-container + _footer.inc.php + tail.sub.php)
- โข ๋์์ธ ํ ํฐ ์ฌ์ฉ (--m-surface, --m-text, --m-text-muted, --m-shadow, --m-radius-lg ๋ฑ)
- โข ์์ฒด SQLite DB ์ฌ์ฉ (G5_DATA_PATH.'/modules/visual-goal-progress-tracker.sqlite')
- โข prepared statement๋ก SQL ์ธ์ ์ ๋ฐฉ์ง
- โข htmlspecialchars()๋ก XSS ๋ฐฉ์ง
- โข ์ธ๋ถ ๋น๋ ๋๊ตฌ ์ฌ์ฉ ๊ธ์ง (์์ PHP + HTML + CSS + JS)
- โข CDN ๋ผ์ด๋ธ๋ฌ๋ฆฌ ์ฌ์ฉ ๊ธ์ง (Vanilla JS๋ก ๊ตฌํ)
ํ๋กฌํํธ ๋ ์ํผ
์๋ ํ๋กฌํํธ๋ฅผ ์์๋๋ก AI์๊ฒ ๋ณด๋ด๋ฉด ๋ฉ๋๋ค. ๋ณต์ฌ ๋ฒํผ์ ๋๋ฌ ๋ฐ๋ก ์ฌ์ฉํ์ธ์.
g5se๊ฐ ์ค์น๋ ๋๋ ํฐ๋ฆฌ(๋ฃจํธ์ `index.php`ยท`app/`์ด ์๋ ๊ณณ)์์ ์์
์ ์์ํฉ๋๋ค. (g5se ์ค์น๋ https://github.com/gnuboard/g5se ์ฐธ๊ณ )
๋ชฉํ ๋ฌ์ฑ ์งํ ๋ฐ ํธ๋์ปค์ ๊ธฐ๋ณธ ๊ณจ๊ฒฉ์ ๋ง๋ค์ด์ฃผ์ธ์.
**์์
๋ด์ฉ:**
1. `app/modules/visual-goal-progress-tracker/index.php` ํ์ผ์ ์์ฑํฉ๋๋ค.
2. modern shell ๊ณจ๊ฒฉ์ ์ ์ฉํฉ๋๋ค:
```php
<?php
include_once('./_common.php');
// SQLite DB ์ฐ๊ฒฐ (๋ชจ๋ ์ ์ฉ DB ํ์ผ)
$db_dir = G5_DATA_PATH.'/modules';
@mkdir($db_dir, 0707, true);
$db_path = $db_dir.'/visual-goal-progress-tracker.sqlite';
$pdo = new PDO('sqlite:'.$db_path);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// ํ
์ด๋ธ ์์ฑ (์ฒซ ์ ๊ทผ ์)
$pdo->exec("
CREATE TABLE IF NOT EXISTS goals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
target INTEGER NOT NULL,
current INTEGER DEFAULT 0,
icon TEXT DEFAULT '๐ฏ',
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)
");
// AJAX ์ฒ๋ฆฌ (๋ชฉํ ์ถ๊ฐ/์์ /์ญ์ )
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
header('Content-Type: application/json');
$action = $_POST['action'] ?? '';
if ($action === 'add') {
$title = trim($_POST['title'] ?? '');
$target = intval($_POST['target'] ?? 0);
$icon = trim($_POST['icon'] ?? '๐ฏ');
if ($title && $target > 0) {
$stmt = $pdo->prepare("INSERT INTO goals (title, target, icon) VALUES (?, ?, ?)");
$stmt->execute([$title, $target, $icon]);
echo json_encode(['success' => true, 'id' => $pdo->lastInsertId()]);
} else {
echo json_encode(['success' => false, 'error' => '์ ๋ชฉ๊ณผ ๋ชฉํ ์์น๋ฅผ ์
๋ ฅํ์ธ์']);
}
exit;
}
if ($action === 'update') {
$id = intval($_POST['id'] ?? 0);
$current = intval($_POST['current'] ?? 0);
if ($id > 0 && $current >= 0) {
$stmt = $pdo->prepare("UPDATE goals SET current = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
$stmt->execute([$current, $id]);
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'error' => '์๋ชป๋ ์์ฒญ']);
}
exit;
}
if ($action === 'delete') {
$id = intval($_POST['id'] ?? 0);
if ($id > 0) {
$stmt = $pdo->prepare("DELETE FROM goals WHERE id = ?");
$stmt->execute([$id]);
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'error' => '์๋ชป๋ ์์ฒญ']);
}
exit;
}
}
// ๋ชฉํ ๋ชฉ๋ก ์กฐํ
$stmt = $pdo->query("SELECT * FROM goals ORDER BY created_at DESC");
$goals = $stmt->fetchAll(PDO::FETCH_ASSOC);
require_once(G5_THEME_PATH.'/modern/_head.inc.php');
include_once('./head.sub.php');
?>
<div class="m-shell">
<?php require G5_THEME_PATH.'/modern/_nav.inc.php'; ?>
<main class="m-container" style="padding: 32px 20px 48px;">
<div style="max-width: 900px; margin: 0 auto;">
<h1 style="font-size: 28px; font-weight: 700; margin-bottom: 8px; color: var(--m-text);">๐ฏ ๋ชฉํ ๋ฌ์ฑ ์งํ ๋ฐ</h1>
<p style="font-size: 15px; color: var(--m-text-muted); margin-bottom: 32px;">๋ชฉํ๋ฅผ ์ค์ ํ๊ณ ์งํ ์ํฉ์ ์๊ฐ์ ์ผ๋ก ํ์ธํ์ธ์</p>
<!-- ๋ชฉํ ์ถ๊ฐ ํผ -->
<div style="background: var(--m-surface); padding: 24px; border-radius: var(--m-radius-lg); box-shadow: var(--m-shadow); margin-bottom: 32px;">
<h2 style="font-size: 18px; font-weight: 600; margin-bottom: 16px; color: var(--m-text);">์ ๋ชฉํ ์ถ๊ฐ</h2>
<form id="addGoalForm" style="display: flex; flex-direction: column; gap: 12px;">
<input type="text" id="goalTitle" placeholder="๋ชฉํ ์ ๋ชฉ (์: ๋
์ 100๊ถ ์ฝ๊ธฐ)" style="padding: 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; background: var(--m-surface); color: var(--m-text);" required>
<div style="display: flex; gap: 12px;">
<input type="number" id="goalTarget" placeholder="๋ชฉํ ์์น" min="1" style="flex: 1; padding: 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; background: var(--m-surface); color: var(--m-text);" required>
<input type="text" id="goalIcon" placeholder="์์ด์ฝ (์: ๐)" maxlength="2" style="width: 80px; padding: 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 14px; text-align: center; background: var(--m-surface); color: var(--m-text);">
</div>
<button type="submit" style="padding: 12px; background: #4F46E5; color: white; border: none; border-radius: 8px; font-weight: 600; cursor: pointer; transition: background 0.2s;">๋ชฉํ ์ถ๊ฐ</button>
</form>
</div>
<!-- ๋ชฉํ ๋ชฉ๋ก -->
<div id="goalsList" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px;">
<?php if (empty($goals)): ?>
<p style="grid-column: 1 / -1; text-align: center; color: var(--m-text-muted); padding: 40px;">์์ง ๋ฑ๋ก๋ ๋ชฉํ๊ฐ ์์ต๋๋ค. ์์์ ์ ๋ชฉํ๋ฅผ ์ถ๊ฐํด๋ณด์ธ์!</p>
<?php else: ?>
<?php foreach ($goals as $goal): ?>
<?php
$percent = $goal['target'] > 0 ? min(100, round(($goal['current'] / $goal['target']) * 100)) : 0;
$barColor = $percent >= 100 ? '#10B981' : ($percent >= 71 ? '#F59E0B' : ($percent >= 31 ? '#F97316' : '#EF4444'));
?>
<div class="goal-card" data-id="<?= $goal['id'] ?>" style="background: var(--m-surface); padding: 20px; border-radius: var(--m-radius-lg); box-shadow: var(--m-shadow);">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 12px;">
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 24px;"><?= htmlspecialchars($goal['icon']) ?></span>
<h3 style="font-size: 16px; font-weight: 600; color: var(--m-text);"><?= htmlspecialchars($goal['title']) ?></h3>
</div>
<button class="delete-btn" data-id="<?= $goal['id'] ?>" style="background: none; border: none; color: #EF4444; cursor: pointer; font-size: 18px;">๐๏ธ</button>
</div>
<div style="margin-bottom: 12px;">
<div style="display: flex; justify-content: space-between; margin-bottom: 6px;">
<span style="font-size: 14px; color: var(--m-text-muted);"><?= $goal['current'] ?> / <?= $goal['target'] ?></span>
<span style="font-size: 14px; font-weight: 600; color: var(--m-text);"><?= $percent ?>%</span>
</div>
<div style="width: 100%; height: 12px; background: #E5E7EB; border-radius: 6px; overflow: hidden;">
<div class="progress-bar" style="width: <?= $percent ?>%; height: 100%; background: <?= $barColor ?>; transition: width 0.5s ease;"></div>
</div>
</div>
<div style="display: flex; gap: 8px;">
<button class="update-btn" data-id="<?= $goal['id'] ?>" data-increment="1" style="flex: 1; padding: 8px; background: #E0E7FF; color: #4F46E5; border: none; border-radius: 6px; font-weight: 600; cursor: pointer;">+1</button>
<button class="update-btn" data-id="<?= $goal['id'] ?>" data-increment="5" style="flex: 1; padding: 8px; background: #DBEAFE; color: #2563EB; border: none; border-radius: 6px; font-weight: 600; cursor: pointer;">+5</button>
<button class="update-btn" data-id="<?= $goal['id'] ?>" data-increment="10" style="flex: 1; padding: 8px; background: #D1FAE5; color: #059669; border: none; border-radius: 6px; font-weight: 600; cursor: pointer;">+10</button>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</main>
<?php require G5_THEME_PATH.'/modern/_footer.inc.php'; ?>
</div>
<?php include_once('./tail.sub.php'); ?>
```
3. `/visual-goal-progress-tracker` URL๋ก ์ ์ํ๋ฉด ๋ชฉํ ํธ๋์ปค ํ์ด์ง๊ฐ ํ์๋ฉ๋๋ค (ํด๋ ๊ธฐ๋ฐ ์๋ ๋ผ์ฐํ
).
๊ธฐ๋ ๊ฒฐ๊ณผ: modern shell์ด ์ ์ฉ๋ ๋ชฉํ ํธ๋์ปค ํ์ด์ง๊ฐ ํ์๋ฉ๋๋ค. ๋ชฉํ ์ถ๊ฐ ํผ๊ณผ ๋ชฉํ ๋ชฉ๋ก์ด ์นด๋ ๋ ์ด์์์ผ๋ก ๋ณด์ด๋ฉฐ, ๊ฐ ๋ชฉํ ์นด๋์๋ ์งํ ๋ฐ์ ํผ์ผํธ๊ฐ ํ์๋ฉ๋๋ค. ์์ง JavaScript๊ฐ ์์ด ๋ฒํผ ํด๋ฆญ ์ ์๋ฌด ๋์๋ ํ์ง ์์ต๋๋ค.
๋ชฉํ ์ถ๊ฐ/์์ /์ญ์ ๊ธฐ๋ฅ์ ๊ตฌํํ๋ JavaScript๋ฅผ ์ถ๊ฐํด์ฃผ์ธ์.
**์์
๋ด์ฉ:**
1. `app/modules/visual-goal-progress-tracker/index.php` ํ์ผ์ `<?php include_once('./tail.sub.php'); ?>` ๋ฐ๋ก ์์ ๋ค์ ์คํฌ๋ฆฝํธ๋ฅผ ์ถ๊ฐํฉ๋๋ค:
```javascript
<script>
// ๋ชฉํ ์ถ๊ฐ
document.getElementById('addGoalForm').addEventListener('submit', async (e) => {
e.preventDefault();
const title = document.getElementById('goalTitle').value.trim();
const target = parseInt(document.getElementById('goalTarget').value);
const icon = document.getElementById('goalIcon').value.trim() || '๐ฏ';
if (!title || target <= 0) {
alert('์ ๋ชฉ๊ณผ ๋ชฉํ ์์น๋ฅผ ์ฌ๋ฐ๋ฅด๊ฒ ์
๋ ฅํ์ธ์');
return;
}
const formData = new FormData();
formData.append('action', 'add');
formData.append('title', title);
formData.append('target', target);
formData.append('icon', icon);
const response = await fetch('/visual-goal-progress-tracker', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
location.reload();
} else {
alert(result.error || '๋ชฉํ ์ถ๊ฐ์ ์คํจํ์ต๋๋ค');
}
});
// ๋ชฉํ ์งํ ์
๋ฐ์ดํธ
document.querySelectorAll('.update-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.id;
const increment = parseInt(btn.dataset.increment);
const card = document.querySelector(`.goal-card[data-id="${id}"]`);
const progressText = card.querySelector('.progress-bar').parentElement.previousElementSibling.querySelector('span:first-child').textContent;
const [current, target] = progressText.split(' / ').map(n => parseInt(n.trim()));
const newCurrent = Math.min(current + increment, target);
const formData = new FormData();
formData.append('action', 'update');
formData.append('id', id);
formData.append('current', newCurrent);
const response = await fetch('/visual-goal-progress-tracker', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
location.reload();
} else {
alert(result.error || '์
๋ฐ์ดํธ์ ์คํจํ์ต๋๋ค');
}
});
});
// ๋ชฉํ ์ญ์
document.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('์ด ๋ชฉํ๋ฅผ ์ญ์ ํ์๊ฒ ์ต๋๊น?')) return;
const id = btn.dataset.id;
const formData = new FormData();
formData.append('action', 'delete');
formData.append('id', id);
const response = await fetch('/visual-goal-progress-tracker', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
location.reload();
} else {
alert(result.error || '์ญ์ ์ ์คํจํ์ต๋๋ค');
}
});
});
</script>
```
2. ์ด์ ๋ชฉํ ์ถ๊ฐ, ์งํ ์
๋ฐ์ดํธ, ์ญ์ ๊ฐ ๋ชจ๋ ๋์ํฉ๋๋ค.
๊ธฐ๋ ๊ฒฐ๊ณผ: ๋ชฉํ ์ถ๊ฐ ํผ์์ ์ ๋ชฉ๊ณผ ๋ชฉํ ์์น๋ฅผ ์ ๋ ฅํ๊ณ ์ ์ถํ๋ฉด ์ ๋ชฉํ ์นด๋๊ฐ ์์ฑ๋ฉ๋๋ค. ๊ฐ ๋ชฉํ ์นด๋์ +1, +5, +10 ๋ฒํผ์ ํด๋ฆญํ๋ฉด ์งํ ์์น๊ฐ ์ฆ๊ฐํ๊ณ ์งํ ๋ฐ๊ฐ ๋์ด๋ฉ๋๋ค. ๐๏ธ ๋ฒํผ์ ํด๋ฆญํ๋ฉด ํ์ธ ํ ๋ชฉํ๊ฐ ์ญ์ ๋ฉ๋๋ค. ๋ฌ์ฑ๋ฅ ์ ๋ฐ๋ผ ์งํ ๋ฐ ์์์ด ๋นจ๊ฐโ์ฃผํฉโ๋ ธ๋โ์ด๋ก์ผ๋ก ๋ณํฉ๋๋ค.
๋ชฉํ ๋ฌ์ฑ ์ ์ถํ ์ ๋๋ฉ์ด์
๊ณผ ๋ฐ์ํ ๋์์ธ์ ๊ฐ์ ํด์ฃผ์ธ์.
**์์
๋ด์ฉ:**
1. `app/modules/visual-goal-progress-tracker/index.php` ํ์ผ์ `<main>` ํ๊ทธ ๋ฐ๋ก ์๋์ ๋ค์ ์คํ์ผ์ ์ถ๊ฐํฉ๋๋ค:
```html
<style>
@keyframes celebrate {
0%, 100% { transform: scale(1); }
25% { transform: scale(1.1) rotate(-5deg); }
50% { transform: scale(1.15); }
75% { transform: scale(1.1) rotate(5deg); }
}
.goal-completed {
animation: celebrate 0.6s ease;
border: 2px solid #10B981 !important;
}
.confetti {
position: fixed;
width: 10px;
height: 10px;
background: #F59E0B;
animation: confetti-fall 2s linear forwards;
pointer-events: none;
z-index: 9999;
}
@keyframes confetti-fall {
to {
transform: translateY(100vh) rotate(360deg);
opacity: 0;
}
}
@media (max-width: 768px) {
#goalsList {
grid-template-columns: 1fr !important;
}
}
.update-btn:hover {
opacity: 0.8;
transform: scale(1.05);
transition: all 0.2s;
}
.delete-btn:hover {
transform: scale(1.2);
transition: transform 0.2s;
}
#addGoalForm button[type="submit"]:hover {
background: #4338CA;
}
</style>
```
2. JavaScript ๋ถ๋ถ์ '๋ชฉํ ์งํ ์
๋ฐ์ดํธ' ์น์
์ ๋ค์๊ณผ ๊ฐ์ด ์์ ํฉ๋๋ค (confetti ํจ๊ณผ ์ถ๊ฐ):
```javascript
// ๋ชฉํ ์งํ ์
๋ฐ์ดํธ
document.querySelectorAll('.update-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset.id;
const increment = parseInt(btn.dataset.increment);
const card = document.querySelector(`.goal-card[data-id="${id}"]`);
const progressText = card.querySelector('.progress-bar').parentElement.previousElementSibling.querySelector('span:first-child').textContent;
const [current, target] = progressText.split(' / ').map(n => parseInt(n.trim()));
const newCurrent = Math.min(current + increment, target);
const wasCompleted = current >= target;
const isNowCompleted = newCurrent >= target;
const formData = new FormData();
formData.append('action', 'update');
formData.append('id', id);
formData.append('current', newCurrent);
const response = await fetch('/visual-goal-progress-tracker', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
// ๋ชฉํ ๋ฌ์ฑ ์๊ฐ ์ถํ ํจ๊ณผ
if (!wasCompleted && isNowCompleted) {
card.classList.add('goal-completed');
createConfetti();
setTimeout(() => {
location.reload();
}, 1500);
} else {
location.reload();
}
} else {
alert(result.error || '์
๋ฐ์ดํธ์ ์คํจํ์ต๋๋ค');
}
});
});
// Confetti ์์ฑ ํจ์
function createConfetti() {
const colors = ['#F59E0B', '#10B981', '#3B82F6', '#EF4444', '#8B5CF6'];
for (let i = 0; i < 30; i++) {
setTimeout(() => {
const confetti = document.createElement('div');
confetti.className = 'confetti';
confetti.style.left = Math.random() * 100 + 'vw';
confetti.style.top = '-10px';
confetti.style.background = colors[Math.floor(Math.random() * colors.length)];
confetti.style.animationDelay = Math.random() * 0.5 + 's';
document.body.appendChild(confetti);
setTimeout(() => confetti.remove(), 2000);
}, i * 50);
}
}
```
3. ์ด์ ๋ชฉํ๋ฅผ 100% ๋ฌ์ฑํ๋ฉด ์นด๋์ ์ถํ ์ ๋๋ฉ์ด์
์ด ์ฌ์๋๊ณ ์์์ confetti๊ฐ ๋จ์ด์ง๋๋ค.
๊ธฐ๋ ๊ฒฐ๊ณผ: ๋ชฉํ๋ฅผ 100% ๋ฌ์ฑํ๋ ์๊ฐ ์นด๋๊ฐ ํ๋/ํ์ ํ๋ ์ ๋๋ฉ์ด์ ์ด ์ฌ์๋๊ณ , ํ๋ฉด์ ์์์ confetti๊ฐ ๋จ์ด์ง๋๋ค. ๋ชจ๋ฐ์ผ์์๋ ๋ชฉํ ์นด๋๊ฐ 1์ด๋ก ์ ๋ ฌ๋๋ฉฐ, ๋ฒํผ ํธ๋ฒ ์ ๋ถ๋๋ฌ์ด ์ ๋๋ฉ์ด์ ์ด ์ ์ฉ๋ฉ๋๋ค. ๋คํฌ๋ชจ๋์์๋ ๋์์ธ ํ ํฐ ๋๋ถ์ ๋ชจ๋ ์์๊ฐ ์์ฐ์ค๋ฝ๊ฒ ํ์๋ฉ๋๋ค.
์ ์ถ๋ ์ํ (0)
๋ก๊ทธ์ธ ํ ์ ์ถ์์ง ์ ์ถ๋ ์ํ์ด ์์ต๋๋ค. ์ฒซ ๋ฒ์งธ ๋์ ์๊ฐ ๋์ด๋ณด์ธ์!