diff --git a/index.html b/index.html
index 68a71c3..fdafdd7 100644
--- a/index.html
+++ b/index.html
@@ -105,6 +105,32 @@
外観
学習設定
+
diff --git a/src/app.js b/src/app.js
index 8da9e87..7836241 100644
--- a/src/app.js
+++ b/src/app.js
@@ -3,14 +3,15 @@
const $ = s => document.querySelector(s);
const $$ = s => [...document.querySelectorAll(s)];
-import { DAY, clamp, fmtInterval, parseCSV } from './core.js';
+import { DAY, clamp, fmtInterval, parseCSV, dayKey as coreDayKey, startOfDay as coreStartOfDay, prevResetDay as corePrevResetDay } from './core.js';
import { db, setDB, blank, load, save, newCard, deckOf, cardsOf, todayLog, counts, buildQueue, plan } from './store.js';
/* DAY, clamp, schedule, fmtInterval, parseCSV come from core.js */
const uid =() => Math.random().toString(36).slice(2, 10);
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
-const dayKey = t => { const d = new Date(t); return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`; };
-const startOfDay = t => { const d = new Date(t); d.setHours(0, 0, 0, 0); return d.getTime(); };
+const dayKey = (t, resetHour = db?.settings?.resetHour ?? 4) => coreDayKey(t, resetHour);
+const startOfDay = (t, resetHour = db?.settings?.resetHour ?? 4) => coreStartOfDay(t, resetHour);
+const prevResetDay = (t, daysAgo) => corePrevResetDay(t, daysAgo, db?.settings?.resetHour ?? 4);
let view = 'home', openDeck = null;
@@ -79,7 +80,7 @@ function renderHome() {
const cells = [];
const back = 83 + ((new Date(today).getDay() + 6) % 7); // pad so the last column ends this week
for (let i = back; i >= 0; i--) {
- const t = today - i * DAY, n = per[dayKey(t)] || 0;
+ const t = prevResetDay(today, i), n = per[dayKey(t)] || 0;
const lvl = n === 0 ? 0 : Math.min(4, Math.ceil(n / max * 4));
cells.push(``);
}
@@ -94,14 +95,14 @@ function renderStats() {
const days = new Set(db.log.map(l => dayKey(l.t)));
let streak = 0, cur = startOfDay(Date.now());
- if (!days.has(dayKey(cur))) cur -= DAY; // today not studied yet: streak still alive
- while (days.has(dayKey(cur))) { streak++; cur -= DAY; }
+ if (!days.has(dayKey(cur))) cur = prevResetDay(cur, 1);
+ while (days.has(dayKey(cur))) { streak++; cur = prevResetDay(cur, 1); }
$('#s-streak').textContent = streak;
const wd = ['日', '月', '火', '水', '木', '金', '土'];
const last7 = [];
for (let i = 6; i >= 0; i--) {
- const d = startOfDay(Date.now()) - i * DAY;
+ const d = prevResetDay(startOfDay(Date.now()), i);
last7.push({ label: wd[new Date(d).getDay()], n: db.log.filter(l => dayKey(l.t) === dayKey(d)).length });
}
$('#bars').innerHTML = barsHTML(last7);
@@ -115,7 +116,7 @@ function renderStats() {
const fc = [];
for (let i = 0; i < 7; i++) {
- const from = startOfDay(Date.now()) + i * DAY, to = from + DAY;
+ const from = prevResetDay(startOfDay(Date.now()), -i), to = prevResetDay(from, -1);
fc.push({ label: i === 0 ? '今日' : wd[new Date(from).getDay()], n: db.cards.filter(c => c.reps && c.due < to && c.due >= (i ? from : 0)).length });
}
$('#forecast').innerHTML = barsHTML(fc);
@@ -127,6 +128,7 @@ function barsHTML(rows) {
/* ---------------- settings ---------------- */
function renderSettings() {
+ $('#set-reset').value = db.settings.resetHour ?? 4;
$('#set-new').value = db.settings.new;
$('#set-max').value = db.settings.max;
$('#set-ret').value = db.settings.ret;
@@ -249,8 +251,8 @@ function finishReview() {
const days = new Set(db.log.map(l => dayKey(l.t)));
let streak = 0, cur = startOfDay(Date.now());
- if (!days.has(dayKey(cur))) cur -= DAY;
- while (days.has(dayKey(cur))) { streak++; cur -= DAY; }
+ if (!days.has(dayKey(cur))) cur = prevResetDay(cur, 1);
+ while (days.has(dayKey(cur))) { streak++; cur = prevResetDay(cur, 1); }
if (streak > 0) {
$('#rv-streak-count').textContent = streak;
@@ -446,11 +448,12 @@ $('#btn-import-csv').onclick = showCSVImportModal;
$('#btn-wipe').onclick = wipe;
$('#file-input').onchange = e => e.target.files[0] && handleFile(e.target.files[0]);
-['new', 'max', 'ret'].forEach(k => {
+['new', 'max', 'ret', 'reset'].forEach(k => {
$('#set-' + k).onchange = e => {
- const lim = k === 'ret' ? [70, 97] : [0, 9999];
- db.settings[k] = clamp(parseInt(e.target.value) || 0, lim[0], lim[1]);
- e.target.value = db.settings[k]; save();
+ const lim = k === 'ret' ? [70, 97] : (k === 'reset' ? [0, 23] : [0, 9999]);
+ const settingKey = k === 'reset' ? 'resetHour' : k;
+ db.settings[settingKey] = clamp(parseInt(e.target.value, 10) || 0, lim[0], lim[1]);
+ e.target.value = db.settings[settingKey]; save(); render();
};
});
diff --git a/src/core.js b/src/core.js
index be1e4ce..cf640eb 100644
--- a/src/core.js
+++ b/src/core.js
@@ -72,13 +72,30 @@ function parseCSV(text) {
const HEADERS = ['front', 'back', 'question', 'answer', 'term', 'definition', 'word', 'meaning', '表', '裏', '質問', '答え', '単語', '意味'];
const isHeader = row => row.slice(0, 2).every(c => HEADERS.includes(c.trim().toLowerCase()));
+const dayKey = (t, resetHour = 4) => {
+ const d = new Date(t);
+ if (d.getHours() < resetHour) d.setDate(d.getDate() - 1);
+ return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
+};
+const startOfDay = (t, resetHour = 4) => {
+ const d = new Date(t);
+ if (d.getHours() < resetHour) d.setDate(d.getDate() - 1);
+ d.setHours(resetHour, 0, 0, 0);
+ return d.getTime();
+};
+const prevResetDay = (t, daysAgo, resetHour = 4) => {
+ const d = new Date(t);
+ d.setDate(d.getDate() - daysAgo);
+ return startOfDay(d.getTime(), resetHour);
+};
+
if (typeof globalThis !== 'undefined') {
- Object.assign(globalThis, { DAY, W, clamp, fsrs, schedule, fmtInterval, parseCSV, intervalDays, retrievability });
+ Object.assign(globalThis, { DAY, W, clamp, fsrs, schedule, fmtInterval, parseCSV, intervalDays, retrievability, dayKey, startOfDay, prevResetDay });
}
if (typeof module !== 'undefined' && module.exports) {
- module.exports = { DAY, W, clamp, fsrs, schedule, fmtInterval, parseCSV, intervalDays, retrievability };
+ module.exports = { DAY, W, clamp, fsrs, schedule, fmtInterval, parseCSV, intervalDays, retrievability, dayKey, startOfDay, prevResetDay };
}
-export { DAY, W, clamp, fsrs, schedule, fmtInterval, parseCSV, intervalDays, retrievability };
+export { DAY, W, clamp, fsrs, schedule, fmtInterval, parseCSV, intervalDays, retrievability, dayKey, startOfDay, prevResetDay };
diff --git a/src/store.js b/src/store.js
index 9411f1a..831ea47 100644
--- a/src/store.js
+++ b/src/store.js
@@ -1,7 +1,7 @@
-import { schedule } from './core.js';
+import { schedule, clamp, dayKey as coreDayKey } from './core.js';
const uid = () => Math.random().toString(36).slice(2, 10);
-const dayKey = t => { const d = new Date(t); return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`; };
+export const dayKey = (t, resetHour = db?.settings?.resetHour ?? 4) => coreDayKey(t, resetHour);
export const KEY = 'core-recall-v1';
export let db = null;
@@ -22,7 +22,12 @@ export function normalizeDB(value) {
cards,
log,
lastBackup: typeof value.lastBackup === 'number' ? value.lastBackup : b.lastBackup,
- settings: Object.assign(b.settings, isRecord(value.settings) ? value.settings : {})
+ settings: Object.assign(b.settings, isRecord(value.settings) ? {
+ ...value.settings,
+ resetHour: typeof value.settings.resetHour === 'number' && Number.isFinite(value.settings.resetHour)
+ ? clamp(Math.floor(value.settings.resetHour), 0, 23)
+ : b.settings.resetHour
+ } : {})
};
}
@@ -32,7 +37,7 @@ export function setDB(newDB) {
export function blank() {
return { decks: [], cards: [], log: [], lastBackup: Date.now(),
- settings: { new: 20, max: 200, ret: 90, theme: 'light' } };
+ settings: { new: 20, max: 200, ret: 90, resetHour: 4, theme: 'light' } };
}
export function load() {
@@ -64,7 +69,7 @@ export function seed() {
export const deckOf = id => db.decks.find(d => d.id === id);
export const cardsOf = id => db.cards.filter(c => c.deck === id);
-export const todayLog = () => { const k = dayKey(Date.now()); return db.log.filter(l => dayKey(l.t) === k); };
+export const todayLog = () => { const k = dayKey(Date.now(), db.settings.resetHour); return db.log.filter(l => dayKey(l.t, db.settings.resetHour) === k); };
export function counts(deckId) {
const now = Date.now(), c = { n: 0, l: 0, d: 0 };
diff --git a/tests/test.cjs b/tests/test.cjs
index cc48d93..cd2c1ad 100644
--- a/tests/test.cjs
+++ b/tests/test.cjs
@@ -12,6 +12,21 @@ const assert = require('assert');
assert.deepStrictEqual(blankState.decks, [], 'null DB should produce empty decks');
assert.strictEqual(blankState.settings.theme, 'light', 'default theme should be light');
+ // --- resetHour default & normalization
+ assert.strictEqual(blankState.settings.resetHour, 4, 'default resetHour should be 4');
+
+ const resetDB = normalizeDB({ settings: { resetHour: 25 } });
+ assert.strictEqual(resetDB.settings.resetHour, 23, 'resetHour > 23 must clamp to 23');
+
+ const negResetDB = normalizeDB({ settings: { resetHour: -5 } });
+ assert.strictEqual(negResetDB.settings.resetHour, 0, 'resetHour < 0 must clamp to 0');
+
+ const nanResetDB = normalizeDB({ settings: { resetHour: NaN } });
+ assert.strictEqual(nanResetDB.settings.resetHour, 4, 'NaN resetHour must default to 4');
+
+ const infResetDB = normalizeDB({ settings: { resetHour: Infinity } });
+ assert.strictEqual(infResetDB.settings.resetHour, 4, 'Infinity resetHour must default to 4');
+
const dirtyDB = {
decks: [{ id: 'd1' }, null, 42, 'str', [], { id: 'd2' }],
cards: [null, { id: 'c1' }, 0],
@@ -90,6 +105,8 @@ const assert = require('assert');
assert.ok(htmlContent.includes('すべてのデータを消去'), 'wipe button label should be すべてのデータを消去');
assert.ok(!htmlContent.includes('すべてのデータを消去(危険)'), 'wipe button label must not include (危険)');
assert.ok(htmlContent.includes(''), 'status bar style meta must be default');
+ assert.ok(htmlContent.includes('id="set-reset"'), 'index.html must include #set-reset select element');
+ assert.ok(jsContent.includes("'resetHour'"), 'app.js must handle resetHour setting change');
assert.ok(jsContent.includes("document.documentElement.className = `theme-${db.settings.theme}`;"), 'applyTheme must update documentElement class');
const htmlBodyMatch = cssContent.match(/html,\s*body\s*\{([^}]+)\}/);
@@ -192,6 +209,32 @@ const assert = require('assert');
const store = await import('../src/store.js');
+ const { dayKey, startOfDay, prevResetDay } = await import('../src/core.js');
+
+ // --- dayKey boundary & resetHour tests (0:00, 4:00, 23:00)
+ const ts4am = new Date(2026, 0, 15, 4, 0, 0).getTime();
+ const ts359am = new Date(2026, 0, 15, 3, 59, 59).getTime();
+ assert.strictEqual(dayKey(ts4am, 4), '2026-1-15', '4:00 AM belongs to current day with resetHour=4');
+ assert.strictEqual(dayKey(ts359am, 4), '2026-1-14', '3:59 AM belongs to previous day with resetHour=4');
+
+ // Reset at 0:00 (midnight)
+ const tsMidnight = new Date(2026, 0, 15, 0, 0, 0).getTime();
+ const ts1159pm = new Date(2026, 0, 14, 23, 59, 59).getTime();
+ assert.strictEqual(dayKey(tsMidnight, 0), '2026-1-15', '0:00 AM belongs to 1/15 with resetHour=0');
+ assert.strictEqual(dayKey(ts1159pm, 0), '2026-1-14', '23:59:59 PM belongs to 1/14 with resetHour=0');
+
+ // Reset at 23:00
+ const ts23pm = new Date(2026, 0, 15, 23, 0, 0).getTime();
+ const ts2259pm = new Date(2026, 0, 15, 22, 59, 59).getTime();
+ assert.strictEqual(dayKey(ts23pm, 23), '2026-1-15', '23:00 PM belongs to current day with resetHour=23');
+ assert.strictEqual(dayKey(ts2259pm, 23), '2026-1-14', '22:59 PM belongs to previous day with resetHour=23');
+
+ // startOfDay and prevResetDay consistency
+ const start4am = startOfDay(ts4am, 4);
+ assert.strictEqual(new Date(start4am).getHours(), 4, 'startOfDay sets hour to resetHour');
+ const prev1Day = prevResetDay(start4am, 1, 4);
+ assert.strictEqual(dayKey(prev1Day, 4), '2026-1-14', 'prevResetDay 1 day ago shifts date key by -1 day');
+
const seeded = store.seed();
assert.strictEqual(seeded.decks[0].name, '英単語(サンプル)', 'seed deck name must be 英単語(サンプル)');