Files
inkling/src/main/services/ContinuityService.ts
altair823 3c780a7464 fix(trash): close active-query invariant leaks (review T5 important #1+#2)
T5 reviewer identified 2 reads outside NoteRepository that were missing the
'WHERE deleted_at IS NULL' filter, breaking the silent invariant beyond the
3 originally-listed methods.

- ContinuityService.get() now excludes trashed notes from streak / weekCount
  / lastNoteAt / recovery-toast math. A trashed note no longer counts toward
  weekly streak (regression: streak felt fake after trash).
- NoteRepository.getPendingCount() now excludes trashed-but-still-pending
  notes. trash() removes the pending_jobs row but leaves notes.ai_status='pending';
  the count would have drifted upward as users trashed pending notes.
- MediaGc.run() gets an inline comment documenting why it intentionally does
  NOT filter — trashed notes still own their media until permanentDelete /
  emptyTrash. Removing here would defeat restore.

Also: migrations.due_date.test.ts had 2 brittle assertions
(latestVersion()===2, user_version===2) that broke with v3. Migration-system
version assertions belong in migrations.test.ts (already covered there);
m002-specific test keeps the due_date column assertion which is version-stable.

Tests: 245 → 265 (+20). typecheck 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:58:18 +09:00

87 lines
2.5 KiB
TypeScript

import type Database from 'better-sqlite3';
import type { WeeklyContinuity } from '@shared/types';
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
const WEEK_TARGET = 7;
const RECOVERY_GAP_DAYS = 7;
function toKstDateKey(d: Date): string {
const k = new Date(d.getTime() + KST_OFFSET_MS);
return k.toISOString().slice(0, 10);
}
function kstMondayOf(d: Date): string {
const k = new Date(d.getTime() + KST_OFFSET_MS);
const dayIdx = (k.getUTCDay() + 6) % 7;
k.setUTCDate(k.getUTCDate() - dayIdx);
return k.toISOString().slice(0, 10);
}
function addDaysIso(iso: string, days: number): string {
const d = new Date(iso + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() + days);
return d.toISOString().slice(0, 10);
}
export class ContinuityService {
constructor(
private db: Database.Database,
private now: () => Date = () => new Date()
) {}
get(): WeeklyContinuity {
const rows = this.db
.prepare(
`SELECT created_at FROM notes WHERE deleted_at IS NULL ORDER BY created_at ASC`
)
.all() as Array<{ created_at: string }>;
const dates = rows.map((r) => new Date(r.created_at));
if (dates.length === 0) {
return {
weekStart: kstMondayOf(this.now()),
weekCount: 0,
weekTarget: WEEK_TARGET,
consecutiveCompleteWeeks: 0,
showRecoveryToast: false,
lastNoteAt: null
};
}
const byWeek = new Map<string, number>();
for (const d of dates) {
const wk = kstMondayOf(d);
byWeek.set(wk, (byWeek.get(wk) ?? 0) + 1);
}
const currentWeek = kstMondayOf(this.now());
const weekCount = byWeek.get(currentWeek) ?? 0;
let consecutive = 0;
let cursor = weekCount >= WEEK_TARGET ? currentWeek : addDaysIso(currentWeek, -7);
while ((byWeek.get(cursor) ?? 0) >= WEEK_TARGET) {
consecutive += 1;
cursor = addDaysIso(cursor, -7);
}
const last = dates[dates.length - 1]!;
const todayKst = toKstDateKey(this.now());
const lastIsToday = toKstDateKey(last) === todayKst;
let showRecoveryToast = false;
if (lastIsToday && dates.length >= 2) {
const prev = dates[dates.length - 2]!;
const gapMs = last.getTime() - prev.getTime();
if (gapMs >= RECOVERY_GAP_DAYS * ONE_DAY_MS) showRecoveryToast = true;
}
return {
weekStart: currentWeek,
weekCount,
weekTarget: WEEK_TARGET,
consecutiveCompleteWeeks: consecutive,
showRecoveryToast,
lastNoteAt: last.toISOString()
};
}
}