Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,32 @@ <h2 class="group-label">外観</h2>

<h2 class="group-label">学習設定</h2>
<ul class="inset-list">
<li><label class="row"><span>日付リセット時刻</span><select id="set-reset">
<option value="0">0:00 (午前0時)</option>
<option value="1">1:00 (午前1時)</option>
<option value="2">2:00 (午前2時)</option>
<option value="3">3:00 (午前3時)</option>
<option value="4">4:00 (午前4時 / デフォルト)</option>
<option value="5">5:00 (午前5時)</option>
<option value="6">6:00 (午前6時)</option>
<option value="7">7:00 (午前7時)</option>
<option value="8">8:00 (午前8時)</option>
<option value="9">9:00 (午前9時)</option>
<option value="10">10:00 (午前10時)</option>
<option value="11">11:00 (午前11時)</option>
<option value="12">12:00 (正午)</option>
<option value="13">13:00 (午後1時)</option>
<option value="14">14:00 (午後2時)</option>
<option value="15">15:00 (午後3時)</option>
<option value="16">16:00 (午後4時)</option>
<option value="17">17:00 (午後5時)</option>
<option value="18">18:00 (午後6時)</option>
<option value="19">19:00 (午後7時)</option>
<option value="20">20:00 (午後8時)</option>
<option value="21">21:00 (午後9時)</option>
<option value="22">22:00 (午後10時)</option>
<option value="23">23:00 (午後11時)</option>
</select></label></li>
<li><label class="row"><span>新規カード/日</span><input type="number" id="set-new" min="0" max="9999" inputmode="numeric"></label></li>
<li><label class="row"><span>最大復習数/日</span><input type="number" id="set-max" min="0" max="9999" inputmode="numeric"></label></li>
<li><label class="row"><span>目標保持率</span><input type="number" id="set-ret" min="70" max="97" inputmode="numeric"></label></li>
Expand Down
31 changes: 17 additions & 14 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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;

Expand Down Expand Up @@ -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(`<i data-l="${lvl}" title="${dayKey(t)}: ${n}"></i>`);
}
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
};
});

Expand Down
23 changes: 20 additions & 3 deletions src/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };



Expand Down
15 changes: 10 additions & 5 deletions src/store.js
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
} : {})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
};
}

Expand All @@ -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() {
Expand Down Expand Up @@ -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 };
Expand Down
43 changes: 43 additions & 0 deletions tests/test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -90,6 +105,8 @@ const assert = require('assert');
assert.ok(htmlContent.includes('すべてのデータを消去</span>'), 'wipe button label should be すべてのデータを消去');
assert.ok(!htmlContent.includes('すべてのデータを消去(危険)'), 'wipe button label must not include (危険)');
assert.ok(htmlContent.includes('<meta name="apple-mobile-web-app-status-bar-style" content="default">'), '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*\{([^}]+)\}/);
Expand Down Expand Up @@ -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 英単語(サンプル)');

Expand Down
Loading