refactor(v026): #3+#19+#34 KST helper 통합 → src/shared/util/kstDate.ts

기존 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) <noreply@anthropic.com>
This commit is contained in:
altair823
2026-05-05 01:27:25 +09:00
parent 075f395b6d
commit 3cfa60bbba
8 changed files with 87 additions and 79 deletions

View File

@@ -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({

View File

@@ -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

View File

@@ -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$/;

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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<InboxState>((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<InboxState>((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;

View File

@@ -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()));
}

View File

@@ -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');
});
});