From 3cfa60bbba896aaed29ae1d14ffb23a2ab09493c Mon Sep 17 00:00:00 2001 From: altair823 Date: Tue, 5 May 2026 01:27:25 +0900 Subject: [PATCH] =?UTF-8?q?refactor(v026):=20#3+#19+#34=20KST=20helper=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20=E2=86=92=20src/shared/util/kstDate.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 src/main/util/kstDate.ts (2 함수) 를 shared 로 이동 + kstTodayAsDate 추가. main + renderer 양쪽 import 가능. 6 callsite 통합: - NoteRepository.findExpiredCandidates (todayInKstString → kstTodayIso) - TelemetryService.todayKstIso (inline 제거) - telemetryStats.kstDate (inline 제거) - AiWorker.todayKstAsDate / todayKstAsIso (inline 제거) - store.snoozeExpired + snoozeRecall (inline 제거 → nextKstMidnightMs) API: kstTodayIso(now) / nextKstMidnightMs(now) / kstTodayAsDate(now) + KST_OFFSET_MS, DAY_MS 상수 export. 단위 +4 cases (boundary, format, midnight, asDate). 418 → 422. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/ai/AiWorker.ts | 17 ++--------- src/main/repository/NoteRepository.ts | 4 +-- src/main/services/TelemetryService.ts | 16 +++------- src/main/services/telemetryStats.ts | 8 ++--- src/main/util/kstDate.ts | 28 ------------------ src/renderer/inbox/store.ts | 15 ++-------- src/shared/util/kstDate.ts | 42 +++++++++++++++++++++++++++ tests/unit/kstDate.test.ts | 36 +++++++++++++++++++---- 8 files changed, 87 insertions(+), 79 deletions(-) delete mode 100644 src/main/util/kstDate.ts create mode 100644 src/shared/util/kstDate.ts diff --git a/src/main/ai/AiWorker.ts b/src/main/ai/AiWorker.ts index b8bd651..9850f27 100644 --- a/src/main/ai/AiWorker.ts +++ b/src/main/ai/AiWorker.ts @@ -3,18 +3,7 @@ import type { Note } from '@shared/types'; import { ProviderHolder } from './ProviderHolder.js'; import { parseAllCandidates } from '../services/dueDateParser.js'; import { ZodError } from 'zod'; - -const KST_OFFSET_MS = 9 * 60 * 60 * 1000; - -function todayKstAsDate(now: Date): Date { - // Returns a Date object whose UTC year/month/day match KST today - const k = new Date(now.getTime() + KST_OFFSET_MS); - return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate())); -} - -function todayKstAsIso(now: Date): string { - return todayKstAsDate(now).toISOString().slice(0, 10); -} +import { kstTodayAsDate, kstTodayIso } from '../../shared/util/kstDate.js'; function classifyReason(err: unknown): 'unreachable' | 'schema' | 'timeout' | 'other' { if (err instanceof ZodError) return 'schema'; @@ -131,8 +120,8 @@ export class AiWorker { const note = this.repo.findById(job.noteId); if (!note || note.deletedAt !== null || note.aiStatus !== 'pending') return; const nowDate = this.now(); - const todayDate = todayKstAsDate(nowDate); - const todayIso = todayKstAsIso(nowDate); + const todayDate = kstTodayAsDate(nowDate); + const todayIso = kstTodayIso(nowDate); const candidates = parseAllCandidates(note.rawText, todayDate); const vocab = this.repo.getTopUsedTags(20); const res = await this.holder.get().generate({ diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index 301fb42..291c4eb 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -1,7 +1,7 @@ import type Database from 'better-sqlite3'; import { v7 as uuidv7, v4 as uuidv4 } from 'uuid'; import type { Note, NoteMedia, NoteTag } from '@shared/types'; -import { todayInKstString } from '../util/kstDate.js'; +import { kstTodayIso } from '../../shared/util/kstDate.js'; export interface CreateNoteInput { rawText: string; } @@ -601,7 +601,7 @@ export class NoteRepository { * Caller may inject `now` for testability; defaults to `new Date()`. */ findExpiredCandidates(now: Date = new Date()): Note[] { - const today = todayInKstString(now); + const today = kstTodayIso(now); const rows = this.db .prepare( `SELECT * FROM notes diff --git a/src/main/services/TelemetryService.ts b/src/main/services/TelemetryService.ts index fe7abd1..3d5e8fc 100644 --- a/src/main/services/TelemetryService.ts +++ b/src/main/services/TelemetryService.ts @@ -2,15 +2,7 @@ import { mkdir, appendFile, readFile, readdir, unlink, writeFile } from 'node:fs import { join } from 'node:path'; import { validateEvent, TelemetryEvent } from './telemetryEvents.js'; import { aggregateStats } from './telemetryStats.js'; - -const KST_OFFSET_MS = 9 * 60 * 60 * 1000; -const DAY_MS = 24 * 60 * 60 * 1000; - -function todayKstIso(now: Date): string { - const k = new Date(now.getTime() + KST_OFFSET_MS); - return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate())) - .toISOString().slice(0, 10); -} +import { kstTodayIso, DAY_MS } from '../../shared/util/kstDate.js'; export interface TelemetryServiceOptions { silent?: boolean; @@ -54,7 +46,7 @@ export class TelemetryService { return { removed }; } const cutoff = new Date(this.now().getTime() - this.retentionDays * DAY_MS); - const cutoffIso = todayKstIso(cutoff); // KST 일자 비교 + const cutoffIso = kstTodayIso(cutoff); // KST 일자 비교 for (const name of entries) { const m = /^events-(\d{4}-\d{2}-\d{2})\.jsonl$/.exec(name); if (!m) continue; @@ -77,7 +69,7 @@ export class TelemetryService { const nowDate = this.now(); const ts = nowDate.toISOString(); const event = validateEvent({ ts, kind: input.kind, payload: input.payload }); - const filePath = join(this.dir, `events-${todayKstIso(nowDate)}.jsonl`); + const filePath = join(this.dir, `events-${kstTodayIso(nowDate)}.jsonl`); try { await mkdir(this.dir, { recursive: true }); await appendFile(filePath, JSON.stringify(event) + '\n', 'utf8'); @@ -96,7 +88,7 @@ export class TelemetryService { return events; } const cutoffMs = this.now().getTime() - this.retentionDays * DAY_MS; - const cutoffIso = todayKstIso(new Date(cutoffMs)); + const cutoffIso = kstTodayIso(new Date(cutoffMs)); // 회차 1 review (PR #13) — 매직 슬라이스 `n.slice(7, 17)` 대신 정규식 capture 그룹으로 // 일자를 추출. prefix 변경 시 정규식 한 곳만 고치면 됨. const datePattern = /^events-(\d{4}-\d{2}-\d{2})\.jsonl$/; diff --git a/src/main/services/telemetryStats.ts b/src/main/services/telemetryStats.ts index 0054fe7..e1b1345 100644 --- a/src/main/services/telemetryStats.ts +++ b/src/main/services/telemetryStats.ts @@ -1,12 +1,8 @@ import type { TelemetryEvent } from './telemetryEvents.js'; - -const KST_OFFSET_MS = 9 * 60 * 60 * 1000; +import { kstTodayIso } from '../../shared/util/kstDate.js'; function kstDate(ts: string): string { - const d = new Date(ts); - const k = new Date(d.getTime() + KST_OFFSET_MS); - return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate())) - .toISOString().slice(0, 10); + return kstTodayIso(new Date(ts)); } interface DailyRow { diff --git a/src/main/util/kstDate.ts b/src/main/util/kstDate.ts deleted file mode 100644 index 8e8e776..0000000 --- a/src/main/util/kstDate.ts +++ /dev/null @@ -1,28 +0,0 @@ -const KST_OFFSET_MS = 9 * 60 * 60 * 1000; - -/** - * Calendar date (YYYY-MM-DD) in Asia/Seoul timezone for the given instant. - * - * v0.2.3 #5 — used by NoteRepository.findExpiredCandidates to compare against - * notes.due_date (also stored as YYYY-MM-DD per slice §F1). - */ -export function todayInKstString(now: Date): string { - const k = new Date(now.getTime() + KST_OFFSET_MS); - return new Date( - Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate()) - ).toISOString().slice(0, 10); -} - -/** - * Epoch ms of the next 00:00 KST strictly after `now`. - * - * v0.2.3 #5 — used by store.snoozeExpired to compute the in-memory snooze - * deadline ("오늘 그만"). - */ -export function nextKstMidnightMs(now: number): number { - const kstNow = now + KST_OFFSET_MS; - // Floor to KST midnight, then add one day. - const kstMidnightFloor = Math.floor(kstNow / 86_400_000) * 86_400_000; - const nextKstMidnight = kstMidnightFloor + 86_400_000; - return nextKstMidnight - KST_OFFSET_MS; -} diff --git a/src/renderer/inbox/store.ts b/src/renderer/inbox/store.ts index 8e7c708..bf88933 100644 --- a/src/renderer/inbox/store.ts +++ b/src/renderer/inbox/store.ts @@ -1,6 +1,7 @@ import { create } from 'zustand'; import type { Note, WeeklyContinuity } from '@shared/types'; import { inboxApi } from './api.js'; +import { nextKstMidnightMs } from '@shared/util/kstDate.js'; export { selectFilteredNotes } from './selectFilteredNotes.js'; @@ -177,12 +178,7 @@ export const useInbox = create((set, get) => ({ }); }, snoozeExpired() { - const KST_OFFSET_MS = 9 * 60 * 60 * 1000; - const now = Date.now(); - const kstNow = now + KST_OFFSET_MS; - const kstMidnightFloor = Math.floor(kstNow / 86_400_000) * 86_400_000; - const nextKstMidnight = kstMidnightFloor + 86_400_000; - set({ expiredSnoozeUntilMs: nextKstMidnight - KST_OFFSET_MS }); + set({ expiredSnoozeUntilMs: nextKstMidnightMs(Date.now()) }); }, async recheckOllama() { const status = await inboxApi.ollamaRecheck(); @@ -212,12 +208,7 @@ export const useInbox = create((set, get) => ({ set({ recallCandidate, recallSnoozeUntilMs: null }); }, async snoozeRecall() { - const KST_OFFSET_MS = 9 * 60 * 60 * 1000; - const now = Date.now(); - const kstNow = now + KST_OFFSET_MS; - const kstMidnightFloor = Math.floor(kstNow / 86_400_000) * 86_400_000; - const nextKstMidnight = kstMidnightFloor + 86_400_000; - set({ recallSnoozeUntilMs: nextKstMidnight - KST_OFFSET_MS }); + set({ recallSnoozeUntilMs: nextKstMidnightMs(Date.now()) }); // m1 fix — candidate=null 인 race 케이스 (사용자가 banner 닫힌 직후 클릭) 시 // snooze 는 적용하되 emit 만 skip. telemetry 누락 받아들임 (의도적). const candidate = get().recallCandidate; diff --git a/src/shared/util/kstDate.ts b/src/shared/util/kstDate.ts new file mode 100644 index 0000000..57b658e --- /dev/null +++ b/src/shared/util/kstDate.ts @@ -0,0 +1,42 @@ +/** + * KST timezone helpers — main + renderer 양쪽에서 import 가능. + * v0.2.6 C1: backlog #3+#19+#34 통합 (기존 src/main/util/kstDate.ts 이동). + */ + +export const KST_OFFSET_MS = 9 * 60 * 60 * 1000; +export const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * KST 자정 기준 today YYYY-MM-DD. + * + * 기존 todayInKstString (NoteRepository.findExpiredCandidates), + * TelemetryService.todayKstIso, telemetryStats.kstDate, AiWorker.todayKstAsIso + * 4 callsite 통합. + */ +export function kstTodayIso(now: Date = new Date()): string { + const k = new Date(now.getTime() + KST_OFFSET_MS); + return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate())) + .toISOString().slice(0, 10); +} + +/** + * 다음 KST 자정의 epoch ms (UTC). + * + * 기존 nextKstMidnightMs (store.snoozeExpired) + store.snoozeRecall inline 통합. + */ +export function nextKstMidnightMs(now: number = Date.now()): number { + const kstNow = now + KST_OFFSET_MS; + const kstMidnightFloor = Math.floor(kstNow / DAY_MS) * DAY_MS; + const nextKstMidnight = kstMidnightFloor + DAY_MS; + return nextKstMidnight - KST_OFFSET_MS; +} + +/** + * KST today (00:00 KST 의 UTC Date 객체). AiWorker 의 dueDateParser 가 candidate 비교용. + * + * 기존 AiWorker.todayKstAsDate 통합. + */ +export function kstTodayAsDate(now: Date = new Date()): Date { + const k = new Date(now.getTime() + KST_OFFSET_MS); + return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate())); +} diff --git a/tests/unit/kstDate.test.ts b/tests/unit/kstDate.test.ts index 657829c..189a5d3 100644 --- a/tests/unit/kstDate.test.ts +++ b/tests/unit/kstDate.test.ts @@ -1,18 +1,29 @@ import { describe, it, expect } from 'vitest'; -import { todayInKstString, nextKstMidnightMs } from '@main/util/kstDate.js'; +import { kstTodayIso, nextKstMidnightMs, kstTodayAsDate } from '@shared/util/kstDate.js'; -describe('todayInKstString', () => { +describe('kstTodayIso', () => { it('returns KST calendar date as YYYY-MM-DD', () => { // 2026-05-01 12:00 UTC = 2026-05-01 21:00 KST - expect(todayInKstString(new Date('2026-05-01T12:00:00Z'))).toBe('2026-05-01'); + expect(kstTodayIso(new Date('2026-05-01T12:00:00Z'))).toBe('2026-05-01'); }); it('handles UTC→KST date rollover (UTC 23:30 → KST next day 08:30)', () => { - expect(todayInKstString(new Date('2026-05-01T23:30:00Z'))).toBe('2026-05-02'); + expect(kstTodayIso(new Date('2026-05-01T23:30:00Z'))).toBe('2026-05-02'); }); it('handles KST midnight exactly (UTC 15:00 = KST 00:00 next day)', () => { - expect(todayInKstString(new Date('2026-05-01T15:00:00Z'))).toBe('2026-05-02'); + expect(kstTodayIso(new Date('2026-05-01T15:00:00Z'))).toBe('2026-05-02'); + }); + + it('boundary — UTC 14:59:59 still KST 23:59:59 same day', () => { + // KST 5/4 23:59:59 = UTC 5/4 14:59:59 + const utcDate = new Date('2026-05-04T14:59:59Z'); + expect(kstTodayIso(utcDate)).toBe('2026-05-04'); + }); + + it('KST 5/5 00:30 (UTC 5/4 15:30) returns 2026-05-05', () => { + const utcDate = new Date('2026-05-04T15:30:00Z'); + expect(kstTodayIso(utcDate)).toBe('2026-05-05'); }); }); @@ -34,4 +45,19 @@ describe('nextKstMidnightMs', () => { expect(next - now).toBeGreaterThan(23 * 60 * 60 * 1000); expect(next - now).toBeLessThan(24 * 60 * 60 * 1000); }); + + it('KST 5/5 00:30 → next KST midnight = 5/6 00:00 KST = 5/5 15:00 UTC', () => { + const utcMs = new Date('2026-05-04T15:30:00Z').getTime(); + const next = nextKstMidnightMs(utcMs); + expect(new Date(next).toISOString()).toBe('2026-05-05T15:00:00.000Z'); + }); +}); + +describe('kstTodayAsDate', () => { + it('returns UTC Date at KST 00:00', () => { + // KST 5/5 00:30 → KST 5/5 00:00 = UTC 5/4 15:00 + const utcDate = new Date('2026-05-04T15:30:00Z'); + const result = kstTodayAsDate(utcDate); + expect(result.toISOString()).toBe('2026-05-05T00:00:00.000Z'); + }); });