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>
87 lines
2.5 KiB
TypeScript
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()
|
|
};
|
|
}
|
|
}
|