Files
habit-tracker/app/static/service-worker.js
T
shinalokandClaude Sonnet 5 bdf9d0bae7 journal: real markdown editor (EasyMDE) with live preview toggle, fix default template
- Replace the plain textarea with EasyMDE (vendored locally, no CDN) for
  markdown authoring: syntax highlighting, smart list continuation, and a
  custom text-based toolbar (built-in EasyMDE toolbar icons require Font
  Awesome from a CDN, which this app doesn't use). unorderedListStyle is set
  to "-" to match the app's own template convention.
- Add a preview/edit toggle button that swaps the editor for the exact same
  server-rendered markdown (via /journal/preview) shown after saving, instead
  of always showing both.
- Fix create/edit entry routes to verify the submitted category_id actually
  belongs to the current user before inserting -- every other write path in
  this app already checked ownership; this one didn't (found while manually
  testing the new editor with a typo'd category id that happened to belong to
  someone else's category, which surfaced as an IntegrityError 500 instead of
  a clean 404-equivalent).
- Fix the default "일상" category template: bare "-" bullet lines don't parse
  as list items in the markdown renderer (they need a trailing space), and
  the content_template validator was silently stripping that trailing space
  off on every save. Backfill migration updates any category still holding
  the old, broken template text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 12:24:18 +09:00

110 lines
3.4 KiB
JavaScript

const CACHE_NAME = "habit-tracker-v6";
const APP_SHELL = [
"/static/css/style.css",
"/static/css/vendor/easymde.min.css",
"/static/js/app.js",
"/static/js/push-register.js",
"/static/js/habit-reorder.js",
"/static/js/journal-category-reorder.js",
"/static/js/journal-editor.js",
"/static/js/vendor/htmx.min.js",
"/static/js/vendor/alpine.min.js",
"/static/js/vendor/sortable.min.js",
"/static/js/vendor/easymde.min.js",
"/static/icons/icon-192.png",
"/static/icons/icon-512.png",
"/static/icons/icon-apple-180.png",
"/static/manifest.json",
"/static/offline.html",
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(CACHE_NAME)
.then((cache) => cache.addAll(APP_SHELL))
.then(() => self.skipWaiting())
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))))
.then(() => self.clients.claim())
);
});
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.pathname.startsWith("/api/")) return;
// 페이지 탐색(/today, /habits, /history 등)은 습관 추가·수정 직후 리다이렉트되는 화면이라
// 캐시된 옛 내용이 먼저 보이면 "저장한 게 사라졌다"처럼 보인다. 네트워크를 우선 시도하고
// 오프라인일 때만 캐시로 폴백한다.
if (request.mode === "navigate") {
event.respondWith(
fetch(request)
.then((response) => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
}
return response;
})
.catch(() => caches.match(request).then((cached) => cached || caches.match("/static/offline.html")))
);
return;
}
// 정적 자산은 캐시 우선 응답 후 백그라운드로 갱신(stale-while-revalidate) — 자주 안 바뀌므로 속도 우선.
event.respondWith(
caches.match(request).then((cached) => {
const network = fetch(request)
.then((response) => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
}
return response;
})
.catch(() => cached);
return cached || network;
})
);
});
self.addEventListener("push", (event) => {
if (!event.data) return;
const data = event.data.json();
event.waitUntil(
self.registration.showNotification(data.title || "해빗랩", {
body: data.body || "",
icon: "/static/icons/icon-192.png",
badge: "/static/icons/icon-192.png",
data: { url: data.url || "/today" },
})
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const targetUrl = (event.notification.data && event.notification.data.url) || "/today";
event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientsList) => {
for (const client of clientsList) {
if (client.url.includes(targetUrl) && "focus" in client) {
return client.focus();
}
}
if (self.clients.openWindow) {
return self.clients.openWindow(targetUrl);
}
})
);
});