feat(posimai-sc): 端末間再開同期を追加

Made-with: Cursor
This commit is contained in:
posimai 2026-04-20 16:46:18 +09:00
parent 492da3f2d9
commit 1e9e19b4b6
5 changed files with 225 additions and 2 deletions

View File

@ -204,7 +204,7 @@ posimai-storeLP
| posimai-store | Stripe / VPS | アプリ販売 LP |
| posimai-log | — | 開発ログビューワーscribe 連携) |
| posimai-boki | — | 簿記2級 学習 PWAlocalStorage |
| posimai-sc | — | 支援士試験 学習 PWAlocalStorage・非公式補助 |
| posimai-sc | VPS APIapp-state | 支援士試験 学習 PWA初学者向け・用語インデックス・試験モード・端末間再開同期 |
---

View File

@ -135,6 +135,14 @@ header{display:flex;align-items:center;justify-content:space-between;padding:0 1
.home-hint strong{color:var(--text2);font-weight:600}
.home-hint kbd{font-family:'JetBrains Mono',monospace;font-size:10px;padding:2px 5px;border:1px solid var(--border);border-radius:4px;background:var(--surface)}
.home-tools{display:flex;justify-content:center;flex-wrap:wrap;gap:10px;margin-bottom:16px}
.sync-card{max-width:540px;margin:0 auto 16px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:12px 14px}
.sync-title{font-size:11px;font-weight:600;color:var(--text3);letter-spacing:.08em;text-transform:uppercase;display:flex;align-items:center;gap:6px;margin-bottom:8px}
.sync-text{font-size:12px;color:var(--text2);line-height:1.6}
.sync-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:8px}
.sync-status{font-size:11px;color:var(--text3)}
.sync-error{font-size:11px;color:var(--err);margin-top:4px}
.sync-input{flex:1;min-width:220px;background:var(--surface2);border:1px solid var(--border);border-radius:var(--radius-sm);padding:7px 10px;font-size:12px;color:var(--text);outline:none;font-family:'JetBrains Mono',monospace}
.sync-input:focus{border-color:var(--accent-border)}
.glossary-intro{font-size:12px;color:var(--text2);line-height:1.65;margin-bottom:12px}
.glossary-search-wrap{position:relative;margin-bottom:8px}
.glossary-search-icon{position:absolute;left:8px;top:50%;transform:translateY(-50%);color:var(--text3);pointer-events:none}
@ -478,6 +486,23 @@ header{display:flex;align-items:center;justify-content:space-between;padding:0 1
</button>
</div>
<div class="sync-card">
<div class="sync-title">
<i data-lucide="cloud" style="width:12px;height:12px"></i>
端末間の再開同期
</div>
<div class="sync-text">PC とスマホで同じ学習状態を再開するため、クラウドに進捗を保存します。</div>
<div class="sync-row" x-show="syncEnabled">
<span class="sync-status" x-text="syncStatus"></span>
<button class="btn-sm" type="button" :disabled="syncBusy" @click="syncNow()">今すぐ同期</button>
</div>
<div class="sync-row" x-show="!syncEnabled">
<input class="sync-input" type="password" placeholder="posimai_api_key または JWT" x-model="syncToken" aria-label="同期キー入力">
<button class="btn-sm btn-accent" type="button" @click="enableSyncWithInput()">同期を有効化</button>
</div>
<div class="sync-error" x-show="syncLastError" x-text="syncLastError"></div>
</div>
<!-- Weak drill button -->
<div style="margin-bottom:20px;display:flex;justify-content:center">
<button class="btn-sm" :class="weakDrillCandidates.length ? 'btn-accent' : ''"

View File

@ -1,6 +1,10 @@
import { DRILLS } from './data/drills.js';
import { CATEGORIES } from './data/categories.js';
const API_BASE = 'https://api.soar-enrich.com/brain/api';
const APP_ID = 'posimai-sc';
const SYNC_KEY_LS = 'posimai_api_key';
function shuffleInPlace(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
@ -127,6 +131,14 @@ document.addEventListener('alpine:init', () => {
examQuestions:[],
examIdx:0,
examQuizState:{},
syncToken:'',
syncEnabled:false,
syncBusy:false,
syncStatus:'未同期',
syncLastError:'',
syncStateAt:0,
syncRemoteAt:0,
syncTimer:null,
init(){
this.categories = CATEGORIES.map(cat=>({
@ -140,6 +152,11 @@ document.addEventListener('alpine:init', () => {
catch{ this.scores = {}; }
try{ this.wrongUnits = JSON.parse(localStorage.getItem('posimai-sc-wrong')||'{}'); }
catch{ this.wrongUnits = {}; }
this.syncStateAt = parseInt(localStorage.getItem('posimai-sc-state-at') || '0', 10) || 0;
const jwtToken = (localStorage.getItem('posimai_token') || '').trim();
const apiKey = (localStorage.getItem(SYNC_KEY_LS) || '').trim();
this.syncToken = jwtToken || apiKey;
this.syncEnabled = !!this.syncToken;
const t = localStorage.getItem('posimai-sc-theme')||'system';
this.isDark = t==='dark'||(t==='system'&&matchMedia('(prefers-color-scheme:dark)').matches);
const uid=(new URLSearchParams(location.search).get('unit')||'').trim().toLowerCase();
@ -153,6 +170,11 @@ document.addEventListener('alpine:init', () => {
this.$watch('search', ()=>{
this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); });
});
if(this.syncEnabled){
this.pullCloudState().catch(()=>{});
} else {
this.syncStatus = '同期キー未設定';
}
if('serviceWorker' in navigator){
navigator.serviceWorker.register('/sw.js').catch(()=>{});
}
@ -180,6 +202,127 @@ document.addEventListener('alpine:init', () => {
history.replaceState(null,'',path);
}catch(e){}
},
nowTs(){
return Date.now();
},
get localStateBlob(){
return {
progress: this.progress || {},
scores: this.scores || {},
wrongUnits: this.wrongUnits || {},
currentUnitId: this.currentUnit?.id || '',
stateAt: this.syncStateAt || this.nowTs()
};
},
applyStateBlob(blob){
const nextProgress = (blob && typeof blob.progress === 'object') ? blob.progress : {};
const nextScores = (blob && typeof blob.scores === 'object') ? blob.scores : {};
const nextWrong = (blob && typeof blob.wrongUnits === 'object') ? blob.wrongUnits : {};
this.progress = { ...nextProgress };
this.scores = { ...nextScores };
this.wrongUnits = { ...nextWrong };
try{ localStorage.setItem('posimai-sc-progress', JSON.stringify(this.progress)); }catch(e){}
try{ localStorage.setItem('posimai-sc-scores', JSON.stringify(this.scores)); }catch(e){}
try{ localStorage.setItem('posimai-sc-wrong', JSON.stringify(this.wrongUnits)); }catch(e){}
if(blob && blob.currentUnitId){
const u = this.allUnits().find(x => x.id === blob.currentUnitId);
if(u) this.currentUnit = u;
}
this.syncStateAt = parseInt(String(blob?.stateAt || this.nowTs()), 10) || this.nowTs();
try{ localStorage.setItem('posimai-sc-state-at', String(this.syncStateAt)); }catch(e){}
this.syncUnitToUrl();
},
markStateChanged(){
this.syncStateAt = this.nowTs();
try{ localStorage.setItem('posimai-sc-state-at', String(this.syncStateAt)); }catch(e){}
if(this.syncEnabled) this.scheduleCloudPush();
},
get syncHeader(){
const token = (localStorage.getItem('posimai_token') || '').trim() || (this.syncToken || '').trim();
return token ? { Authorization: 'Bearer ' + token } : null;
},
async pullCloudState(){
const hdr = this.syncHeader;
if(!hdr){
this.syncEnabled = false;
this.syncStatus = '同期キー未設定';
return;
}
this.syncBusy = true;
this.syncStatus = '同期を確認中...';
this.syncLastError = '';
try{
const res = await fetch(`${API_BASE}/app-state/${APP_ID}`, { headers: hdr });
if(res.status===404){
this.syncEnabled = true;
this.syncStatus = 'クラウド初期化前(この端末の状態を保存できます)';
return;
}
if(!res.ok) throw new Error('HTTP '+res.status);
const data = await res.json();
const remoteState = data?.state || {};
const remoteAt = parseInt(String(remoteState?.stateAt || 0), 10) || 0;
this.syncRemoteAt = remoteAt;
if(remoteAt > (this.syncStateAt || 0)){
this.applyStateBlob(remoteState);
this.syncStatus = 'クラウドから最新状態を復元';
} else if(remoteAt > 0){
this.syncStatus = 'この端末の状態が最新';
} else {
this.syncStatus = 'クラウド初期化前(この端末の状態を保存できます)';
}
}catch(e){
this.syncLastError = String(e?.message || e || '同期失敗');
this.syncStatus = '同期エラー';
}finally{
this.syncBusy = false;
}
},
async pushCloudState(){
const hdr = this.syncHeader;
if(!hdr || !this.syncEnabled) return;
this.syncBusy = true;
this.syncStatus = 'クラウドへ保存中...';
this.syncLastError = '';
try{
const payload = this.localStateBlob;
const res = await fetch(`${API_BASE}/app-state/${APP_ID}`, {
method:'PUT',
headers:{ 'Content-Type':'application/json', ...hdr },
body: JSON.stringify({ state: payload })
});
if(!res.ok) throw new Error('HTTP '+res.status);
this.syncRemoteAt = payload.stateAt;
this.syncStatus = 'クラウド同期済み';
}catch(e){
this.syncLastError = String(e?.message || e || '同期失敗');
this.syncStatus = '同期エラー';
}finally{
this.syncBusy = false;
}
},
scheduleCloudPush(){
if(this.syncTimer) clearTimeout(this.syncTimer);
this.syncTimer = setTimeout(()=>{ this.pushCloudState().catch(()=>{}); }, 800);
},
enableSyncWithInput(){
const token = (this.syncToken || '').trim();
if(!token){
this.syncStatus = '同期キー未設定';
return;
}
localStorage.setItem(SYNC_KEY_LS, token);
this.syncEnabled = true;
this.pullCloudState().catch(()=>{});
},
async syncNow(){
if(!this.syncEnabled){
this.syncStatus = '同期キー未設定';
return;
}
await this.pushCloudState();
await this.pullCloudState();
},
goHome(){
this.currentUnit=null;
@ -194,6 +337,7 @@ document.addEventListener('alpine:init', () => {
this.examQuestions=[];
this.examIdx=0;
this.examQuizState={};
this.markStateChanged();
this.syncUnitToUrl();
this.$nextTick(()=>{ if(window.lucide) lucide.createIcons(); });
},
@ -251,6 +395,7 @@ document.addEventListener('alpine:init', () => {
if(!this.currentUnit) return;
this.progress[this.currentUnit.id]=true;
localStorage.setItem('posimai-sc-progress',JSON.stringify(this.progress));
this.markStateChanged();
},
bestScore(id){
const s=this.scores[id];
@ -269,6 +414,7 @@ document.addEventListener('alpine:init', () => {
if(!prev||correct>prev.best){
this.scores[id]={best:correct,total};
localStorage.setItem('posimai-sc-scores',JSON.stringify(this.scores));
this.markStateChanged();
}
},
get freqBadgeCls(){
@ -295,6 +441,7 @@ document.addEventListener('alpine:init', () => {
this.quizState={};
this.conceptExpanded=true;
this.stepMode=false;
this.markStateChanged();
this.syncUnitToUrl();
this.$nextTick(()=>{
if(window.lucide) lucide.createIcons();
@ -317,12 +464,14 @@ document.addEventListener('alpine:init', () => {
if(!this.progress[uid]){
this.progress[uid]=true;
try{ localStorage.setItem('posimai-sc-progress',JSON.stringify(this.progress)); }catch(e){}
this.markStateChanged();
}
} else {
this.wrongUnits[uid]=(this.wrongUnits[uid]||0)+1;
}
try{ localStorage.setItem('posimai-sc-wrong',JSON.stringify(this.wrongUnits)); }catch(e){}
this.wrongUnits={...this.wrongUnits};
this.markStateChanged();
},
get weakUnits(){
return this.allUnits().filter(u=>this.wrongUnits[u.id]>0)
@ -637,6 +786,7 @@ document.addEventListener('alpine:init', () => {
delete this.wrongUnits[unit.id];
localStorage.setItem('posimai-sc-wrong',JSON.stringify(this.wrongUnits));
this.wrongUnits={...this.wrongUnits};
this.markStateChanged();
}
if(this.weakDrillUnitIdx<this.weakDrillUnits.length-1){
this.weakDrillUnitIdx++;

View File

@ -1,5 +1,5 @@
// posimai-sc SW — same-origin の静的資産のみキャッシュCDN は対象外)
const CACHE = 'posimai-sc-v4';
const CACHE = 'posimai-sc-v5';
const STATIC = [
'/',
'/index.html',

View File

@ -842,6 +842,9 @@ async function togetherEnsureMemberForShare(pool, res, shareId, username, jwtUse
}
}
const APP_STATE_DIR = path.join(__dirname, 'app-state');
if (!fs.existsSync(APP_STATE_DIR)) fs.mkdirSync(APP_STATE_DIR, { recursive: true });
// ── ルーター ──────────────────────────────
function buildRouter() {
const r = express.Router();
@ -897,6 +900,51 @@ function buildRouter() {
res.json({ ok: true, userId: req.userId });
});
// ── App state sync (cross-device resume) ──────────────────────────
r.get('/app-state/:appId', authMiddleware, (req, res) => {
const appId = String(req.params.appId || '').trim();
if (!/^[a-z0-9-]{2,40}$/.test(appId)) {
return res.status(400).json({ error: 'invalid_app_id' });
}
const userDir = path.join(APP_STATE_DIR, req.userId);
const file = path.join(userDir, `${appId}.json`);
if (!fs.existsSync(file)) return res.status(404).json({ error: 'not_found' });
try {
const raw = fs.readFileSync(file, 'utf8');
const parsed = JSON.parse(raw);
return res.json({ ok: true, appId, userId: req.userId, state: parsed.state || {} });
} catch (e) {
console.error('[AppState] read error:', e.message);
return res.status(500).json({ error: 'read_failed' });
}
});
r.put('/app-state/:appId', authMiddleware, (req, res) => {
const appId = String(req.params.appId || '').trim();
if (!/^[a-z0-9-]{2,40}$/.test(appId)) {
return res.status(400).json({ error: 'invalid_app_id' });
}
const state = req.body?.state;
if (!state || typeof state !== 'object' || Array.isArray(state)) {
return res.status(400).json({ error: 'invalid_state' });
}
// 安全上限: 100KB
const payload = JSON.stringify({ appId, userId: req.userId, state, updatedAt: new Date().toISOString() });
if (Buffer.byteLength(payload, 'utf8') > 100 * 1024) {
return res.status(413).json({ error: 'state_too_large' });
}
const userDir = path.join(APP_STATE_DIR, req.userId);
const file = path.join(userDir, `${appId}.json`);
try {
if (!fs.existsSync(userDir)) fs.mkdirSync(userDir, { recursive: true });
fs.writeFileSync(file, payload, 'utf8');
return res.json({ ok: true, appId, updatedAt: new Date().toISOString() });
} catch (e) {
console.error('[AppState] write error:', e.message);
return res.status(500).json({ error: 'write_failed' });
}
});
// ── Auth: Magic Link ─────────────────────────────────────────────
// POST /api/auth/magic-link/send