feat: 로컬 알림 — 가계부 기록 리마인더 + 구독 만료 임박 (앱 전용)
CI / build (push) Failing after 14m29s

- @capacitor/local-notifications 추가, src/native/reminders.js
- 매일 설정 시각(기본 21시) 가계부 미기록 시 알림(오늘 기록하면 오늘자 제외, 7일 롤링)
- 구독 해지 상태 만료 3일 전 알림
- 설정에 알림 받기 토글 + 시간 선택(앱에서만), 권한 요청
- 앱 시작/홈/내역에서 스케줄 보정

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-28 17:42:00 +09:00
parent fa3b12b369
commit 127551da12
7 changed files with 264 additions and 2 deletions
+124
View File
@@ -0,0 +1,124 @@
import { Capacitor } from '@capacitor/core'
import { Preferences } from '@capacitor/preferences'
// 로컬 알림(앱 전용) — 가계부 기록 리마인더 + 구독 만료 임박 알림.
// 서버 푸시(FCM) 없이 기기에서 예약되며, 앱을 켜지 않아도 예약 시간에 표시된다.
const PREF_ENABLED = 'notif_enabled'
const PREF_HOUR = 'notif_hour'
const ENTRY_BASE = 5000 // 가계부 리마인더 id 5000~5006 (7일 롤링)
const ENTRY_DAYS = 7
const EXPIRY_ID = 5100 // 구독 만료 임박 id
let LN = null
async function ln() {
if (!Capacitor.isNativePlatform()) return null
if (!LN) LN = (await import('@capacitor/local-notifications')).LocalNotifications
return LN
}
async function cancelIds(ids) {
const n = await ln()
if (!n) return
try {
await n.cancel({ notifications: ids.map((id) => ({ id })) })
} catch {
/* 무시 */
}
}
export const reminders = {
isNative() {
return Capacitor.isNativePlatform()
},
async getSettings() {
const e = (await Preferences.get({ key: PREF_ENABLED })).value
const h = (await Preferences.get({ key: PREF_HOUR })).value
return { enabled: e === '1', hour: h != null ? Number(h) : 21 }
},
async setSettings({ enabled, hour }) {
await Preferences.set({ key: PREF_ENABLED, value: enabled ? '1' : '0' })
if (hour != null) await Preferences.set({ key: PREF_HOUR, value: String(hour) })
},
/** 알림 권한 요청 (Android 13+). 허용되면 true */
async ensurePermission() {
const n = await ln()
if (!n) return false
let p = await n.checkPermissions()
if (p.display !== 'granted') p = await n.requestPermissions()
return p.display === 'granted'
},
/** 가계부 리마인더 — 향후 7일 매일 예약(오늘 이미 기록했으면 오늘은 제외) */
async scheduleEntryReminder(hasEntryToday = false) {
const n = await ln()
if (!n) return
await cancelIds(Array.from({ length: ENTRY_DAYS }, (_, i) => ENTRY_BASE + i))
const { enabled, hour } = await this.getSettings()
if (!enabled) return
const now = new Date()
const list = []
for (let i = 0; i < ENTRY_DAYS; i++) {
const at = new Date(now)
at.setDate(now.getDate() + i)
at.setHours(hour, 0, 0, 0)
if (at <= now) continue
if (i === 0 && hasEntryToday) continue
list.push({
id: ENTRY_BASE + i,
title: '돈돼지 가계부',
body: '오늘 가계부 기록을 잊지 않으셨나요? 📝',
schedule: { at },
})
}
if (list.length) {
try {
await n.schedule({ notifications: list })
} catch {
/* 무시 */
}
}
},
/** 오늘 기록을 완료하면 오늘자 리마인더만 취소 */
async clearTodayReminder() {
await cancelIds([ENTRY_BASE])
},
/**
* 구독 만료 임박 알림 — 해지(자동갱신 off) 상태에서 만료 3일 전 예약.
* sub: { premium, autoRenew, expiresAt }
*/
async scheduleExpiryReminder(sub) {
const n = await ln()
if (!n) return
await cancelIds([EXPIRY_ID])
const { enabled } = await this.getSettings()
if (!enabled || !sub || !sub.premium || sub.autoRenew || !sub.expiresAt) return
const exp = new Date(sub.expiresAt)
if (Number.isNaN(exp.getTime())) return
const at = new Date(exp.getTime() - 3 * 24 * 60 * 60 * 1000)
at.setHours(10, 0, 0, 0)
if (at <= new Date()) return
try {
await n.schedule({
notifications: [{
id: EXPIRY_ID,
title: '돈돼지 가계부',
body: `프리미엄 구독이 ${exp.getFullYear()}.${exp.getMonth() + 1}.${exp.getDate()}에 만료돼요. 계속 이용하려면 갱신해 주세요.`,
schedule: { at },
}],
})
} catch {
/* 무시 */
}
},
/** 알림 전체 해제 (토글 off) */
async cancelAll() {
await cancelIds([...Array.from({ length: ENTRY_DAYS }, (_, i) => ENTRY_BASE + i), EXPIRY_ID])
},
}