Initial commit: habit tracker PWA with Google OAuth, push notifications

FastAPI + SQLAlchemy/Alembic + MariaDB backend with Jinja2/htmx/Alpine
server-rendered frontend. Multi-user via Google OAuth, daily habit
tracking, monthly/weekly history views, Web Push reminders via
APScheduler, and PWA support (manifest, service worker, offline caching).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 18:06:17 +09:00
co-authored by Claude Sonnet 5
commit cee589bb3e
80 changed files with 4695 additions and 0 deletions
@@ -0,0 +1,28 @@
<div class="field">
<label>시행 요일</label>
<div class="weekday-picker">
<template x-for="(day, idx) in days" :key="idx">
<button
type="button"
class="weekday-pill"
:class="{ selected: (mask & (1 << idx)) !== 0 }"
@click="if (mask !== (1 << idx)) mask = mask ^ (1 << idx)"
x-text="day"
></button>
</template>
<button
type="button"
class="everyday-btn"
:class="{ selected: mask === 127 }"
@click="mask = 127"
>매일</button>
</div>
</div>
<div class="field">
<label style="display:flex; align-items:center; gap:8px;">
<input type="checkbox" x-model="alarmOn" style="width:auto;" />
알람 사용
</label>
<input x-show="alarmOn" x-cloak type="time" name="reminder_time" value="{{ reminder_value|default('') }}" style="margin-top:6px;" />
</div>
+42
View File
@@ -0,0 +1,42 @@
<div
class="card"
x-data="{
open: false,
mask: 127,
alarmOn: false,
days: ['월', '화', '수', '목', '금', '토', '일'],
}"
>
<button type="button" class="btn btn-secondary btn-block" @click="open = !open">
<span x-show="!open">{{ '+ 멈추고 싶은 습관 추가' if habit_type == 'quit' else '+ 만들고 싶은 습관 추가' }}</span>
<span x-show="open" x-cloak>닫기</span>
</button>
<form
x-show="open"
x-cloak
style="margin-top: var(--space-2);"
hx-post="/habits/new"
hx-target="#habit-form-error-{{ habit_type }}"
hx-swap="innerHTML"
>
<input type="hidden" name="habit_type" value="{{ habit_type }}" />
<input type="hidden" name="weekdays_mask" :value="mask" />
<div class="field">
<label for="name-{{ habit_type }}">습관 이름</label>
<input type="text" id="name-{{ habit_type }}" name="name" required placeholder="{{ '예: 물 2L 마시기' if habit_type == 'build' else '예: 야식 끊기' }}" />
</div>
<div class="field">
<label for="condition-{{ habit_type }}">달성 조건 (선택)</label>
<input type="text" id="condition-{{ habit_type }}" name="condition_text" placeholder="{{ '예: 하루 8잔 이상' if habit_type == 'build' else '예: 주 3회 이하로 줄이기' }}" />
</div>
{% include "partials/_weekday_alarm_fields.html" %}
<div id="habit-form-error-{{ habit_type }}" class="form-error-text"></div>
<button type="submit" class="btn btn-primary btn-block">추가하기</button>
</form>
</div>
+94
View File
@@ -0,0 +1,94 @@
<div
data-habit-id="{{ habit.id }}"
x-data="{
editing: false,
mask: {{ habit.weekdays_mask }},
alarmOn: {{ 'true' if habit.reminder_time else 'false' }},
days: ['월', '화', '수', '목', '금', '토', '일'],
}"
>
<div class="habit-item" x-show="!editing">
<div class="habit-item-main">
<span class="drag-handle" aria-hidden="true"></span>
<div>
<div class="habit-item-name">{{ habit.name }}</div>
{% if habit.condition_text %}<div class="habit-item-condition">{{ habit.condition_text }}</div>{% endif %}
<div class="habit-item-meta">
{{ weekday_label(habit.weekdays_mask) }}
{% if habit.reminder_time %}· 알람 {{ habit.reminder_time.strftime("%H:%M") }}{% endif %}
</div>
{% set stats = stats_map[habit.id] %}
{% if stats.scheduled_days > 0 %}
<div class="habit-item-stats">
<span class="badge">완료율 {{ stats.completion_rate }}%</span>
{% if stats.current_streak > 0 %}
<span class="badge{{ ' badge-milestone' if is_milestone_streak(stats.current_streak) }}">
{{ '🏆' if is_milestone_streak(stats.current_streak) else '🔥' }} 연속 {{ stats.current_streak }}일
</span>
{% endif %}
</div>
{% endif %}
</div>
</div>
<div class="habit-item-actions">
<button type="button" class="btn btn-secondary" @click="editing = true">수정</button>
{% if habit.status.value == "active" %}
<button
type="button"
class="btn btn-secondary"
hx-post="/habits/{{ habit.id }}/complete"
hx-target="body"
hx-swap="none"
hx-confirm="'{{ habit.name }}' 습관을 완료 처리할까요?"
>완료 처리</button>
{% else %}
<button
type="button"
class="btn btn-secondary"
hx-post="/habits/{{ habit.id }}/reactivate"
hx-target="body"
hx-swap="none"
>다시 진행</button>
{% endif %}
<button
type="button"
class="btn btn-danger-ghost"
hx-delete="/habits/{{ habit.id }}/delete"
hx-target="body"
hx-swap="none"
hx-confirm="'{{ habit.name }}' 습관을 삭제할까요? 기록도 함께 삭제됩니다."
>삭제</button>
</div>
</div>
<form
x-show="editing"
x-cloak
style="padding: 12px 4px; border-bottom: 1px solid var(--color-border);"
hx-post="/habits/{{ habit.id }}/edit"
hx-target="#habit-edit-error-{{ habit.id }}"
hx-swap="innerHTML"
>
<input type="hidden" name="weekdays_mask" :value="mask" />
<div class="field">
<label for="edit-name-{{ habit.id }}">습관 이름</label>
<input type="text" id="edit-name-{{ habit.id }}" name="name" required value="{{ habit.name }}" />
</div>
<div class="field">
<label for="edit-condition-{{ habit.id }}">달성 조건 (선택)</label>
<input type="text" id="edit-condition-{{ habit.id }}" name="condition_text" value="{{ habit.condition_text or '' }}" />
</div>
{% set reminder_value = habit.reminder_time.strftime("%H:%M") if habit.reminder_time else "" %}
{% include "partials/_weekday_alarm_fields.html" %}
<div id="habit-edit-error-{{ habit.id }}" class="form-error-text"></div>
<div style="display:flex; gap:8px;">
<button type="submit" class="btn btn-primary" style="flex:1;">저장</button>
<button type="button" class="btn btn-secondary" @click="editing = false">취소</button>
</div>
</form>
</div>
+43
View File
@@ -0,0 +1,43 @@
{% if celebrate_streak %}
<div class="celebration-banner" x-data="{ show: true }" x-init="setTimeout(() => show = false, 4000)" x-show="show" x-transition>
🎉 <strong>{{ celebrate_habit_name }}</strong> {{ celebrate_streak }}일 연속 달성! 축하해요!
</div>
{% endif %}
<div
class="card"
style="display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:8px;"
x-data="{ pushState: 'checking' }"
x-init="habitPush.getSubscriptionState().then(s => pushState = s)"
>
<h2 style="margin:0;">오늘</h2>
<div style="display:flex; align-items:center; gap:8px;">
<span class="badge">{{ checked_count }}/{{ total_count }} 완료</span>
<button
type="button"
class="btn btn-secondary"
x-show="pushState === 'unsubscribed'"
x-cloak
@click="habitPush.subscribePush().then(ok => { if (ok) pushState = 'subscribed'; })"
>알림 켜기</button>
<span class="badge" x-show="pushState === 'subscribed'" x-cloak>🔔 알림 켜짐</span>
</div>
</div>
<h2>형성 습관</h2>
<div class="card">
{% for item in build_items %}
{% include "partials/today_item.html" %}
{% else %}
<div class="empty-state">오늘 예정된 형성 습관이 없어요.</div>
{% endfor %}
</div>
<h2>중단 습관</h2>
<div class="card">
{% for item in quit_items %}
{% include "partials/today_item.html" %}
{% else %}
<div class="empty-state">오늘 예정된 중단 습관이 없어요.</div>
{% endfor %}
</div>
+27
View File
@@ -0,0 +1,27 @@
<div class="habit-item{{ ' today-checked' if item.checked }}">
<div class="habit-item-main">
<button
type="button"
class="check-indicator{{ ' checked' if item.checked }}"
hx-post="/today/{{ item.habit_id }}/toggle"
hx-target="#today-content"
hx-swap="innerHTML"
aria-label="{{ item.name }} 체크"
>{% if item.checked %}&#10003;{% endif %}</button>
<div>
<div class="habit-item-name">{{ item.name }}</div>
{% if item.condition_text %}<div class="habit-item-condition">{{ item.condition_text }}</div>{% endif %}
{% if item.reminder_time %}<div class="habit-item-meta">알람 {{ item.reminder_time }}</div>{% endif %}
{% if item.scheduled_days > 0 %}
<div class="habit-item-stats">
<span class="badge">완료율 {{ item.completion_rate }}%</span>
{% if item.current_streak > 0 %}
<span class="badge{{ ' badge-milestone' if is_milestone_streak(item.current_streak) }}">
{{ '🏆' if is_milestone_streak(item.current_streak) else '🔥' }} 연속 {{ item.current_streak }}일
</span>
{% endif %}
</div>
{% endif %}
</div>
</div>
</div>