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
+35
View File
@@ -0,0 +1,35 @@
(function () {
if ("serviceWorker" in navigator) {
window.addEventListener("load", function () {
navigator.serviceWorker.register("/service-worker.js").catch(function (err) {
console.error("서비스워커 등록 실패", err);
});
});
}
function isIos() {
return /iphone|ipad|ipod/i.test(window.navigator.userAgent);
}
function isStandalone() {
return (
("standalone" in window.navigator && window.navigator.standalone) ||
window.matchMedia("(display-mode: standalone)").matches
);
}
document.addEventListener("DOMContentLoaded", function () {
var banner = document.getElementById("ios-install-banner");
if (!banner) return;
var dismissed = localStorage.getItem("iosInstallBannerDismissed");
if (isIos() && !isStandalone() && !dismissed) {
banner.style.display = "flex";
}
});
window.dismissIosInstallBanner = function () {
var banner = document.getElementById("ios-install-banner");
if (banner) banner.style.display = "none";
localStorage.setItem("iosInstallBannerDismissed", "1");
};
})();
+27
View File
@@ -0,0 +1,27 @@
(function () {
function initSortable() {
var list = document.getElementById("habit-list");
if (!list || typeof Sortable === "undefined") return;
Sortable.create(list, {
handle: ".drag-handle",
animation: 150,
forceFallback: true, // iOS Safari는 네이티브 HTML5 D&D 터치 지원이 불안정해서 자체 포인터 시뮬레이션을 강제한다
fallbackTolerance: 3,
onEnd: function () {
var ids = Array.from(list.children)
.map(function (el) { return el.getAttribute("data-habit-id"); })
.filter(Boolean)
.map(Number);
fetch("/api/habits/reorder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ habit_ids: ids }),
});
},
});
}
document.addEventListener("DOMContentLoaded", initSortable);
})();
+63
View File
@@ -0,0 +1,63 @@
(function () {
function urlBase64ToUint8Array(base64String) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = atob(base64);
const output = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; i++) {
output[i] = rawData.charCodeAt(i);
}
return output;
}
function isPushSupported() {
return "serviceWorker" in navigator && "PushManager" in window;
}
async function getSubscriptionState() {
if (!isPushSupported()) return "unsupported";
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
return sub ? "subscribed" : "unsubscribed";
}
async function subscribePush() {
if (!isPushSupported()) {
alert("이 브라우저는 알림을 지원하지 않아요.");
return false;
}
const permission = await Notification.requestPermission();
if (permission !== "granted") {
alert("알림 권한이 허용되지 않았어요.");
return false;
}
const reg = await navigator.serviceWorker.ready;
const { publicKey } = await fetch("/api/push/vapid-public-key").then((r) => r.json());
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
});
await fetch("/api/push/subscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(sub.toJSON()),
});
return true;
}
async function unsubscribePush() {
if (!isPushSupported()) return;
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
if (!sub) return;
await fetch("/api/push/unsubscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint: sub.endpoint }),
});
await sub.unsubscribe();
}
window.habitPush = { getSubscriptionState, subscribePush, unsubscribePush };
})();
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long