This commit is contained in:
2026-08-18 23:30:33 +02:00
parent 52b46f09a4
commit 4f5e300b4a
4 changed files with 109 additions and 13 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
const SHELL_CACHE='fixfin-shell-v3.14'; const SHELL_CACHE='fixfin-shell-v3.15';
const DATA_CACHE='fixfin-data-v1'; const DATA_CACHE='fixfin-data-v1';
const APP_SHELL=['/','/manifest.webmanifest','/icons/icon-192.png','/icons/icon-512.png','/icons/apple-touch-icon.png']; const APP_SHELL=['/','/manifest.webmanifest','/icons/icon-192.png','/icons/icon-512.png','/icons/apple-touch-icon.png'];
+49 -7
View File
@@ -6,13 +6,16 @@ import { fileURLToPath } from 'node:url';
const __filename=fileURLToPath(import.meta.url); const __dirname=path.dirname(__filename); const app=express(); const __filename=fileURLToPath(import.meta.url); const __dirname=path.dirname(__filename); const app=express();
const port=Number(process.env.PORT||3000); const dataFile=process.env.DATA_FILE||'/app/data/finance.json'; const dataDir=path.dirname(dataFile); const backupFile=`${dataFile}.bak`; const port=Number(process.env.PORT||3000); const dataFile=process.env.DATA_FILE||'/app/data/finance.json'; const dataDir=path.dirname(dataFile); const backupFile=`${dataFile}.bak`;
const pushoverFile=process.env.PUSHOVER_FILE||path.join(dataDir,'pushover.json'); const pushoverStateFile=process.env.PUSHOVER_STATE_FILE||path.join(dataDir,'pushover-state.json');
const reminderHour=Math.min(23,Math.max(0,Number(process.env.PUSHOVER_REMINDER_HOUR||8))); const PUSHOVER_ENDPOINT='https://api.pushover.net/1/messages.json';
const validIntervals=new Set(['monthly','quarterly','semiannual','yearly']); const validIntervals=new Set(['monthly','quarterly','semiannual','yearly']);
const validScenarioTypes=new Set(['income','expense','transfer']); const validScenarioTypes=new Set(['income','expense','transfer']);
const emptyData=()=>({version:6,settings:{includePeriodicInBalance:true},accounts:[],categories:[],incomes:[],expenses:[],transfers:[],scenarios:[]}); const emptyData=()=>({version:7,settings:{includePeriodicInBalance:true},accounts:[],categories:[],incomes:[],expenses:[],transfers:[],scenarios:[]});
const isFiniteNumber=value=>typeof value==='number'&&Number.isFinite(value); const isFiniteNumber=value=>typeof value==='number'&&Number.isFinite(value);
function normalizeMovement(item={}) { return { ...item, interval: validIntervals.has(item.interval)?item.interval:'monthly', dueMonth: item.interval && item.interval!=='monthly' && Number.isInteger(Number(item.dueMonth)) ? Number(item.dueMonth) : null }; } function normalizeMovement(item={}) { return { ...item, interval: validIntervals.has(item.interval)?item.interval:'monthly', dueMonth: item.interval && item.interval!=='monthly' && Number.isInteger(Number(item.dueMonth)) ? Number(item.dueMonth) : null }; }
function normalizeCategorizedMovement(item={}) { const normalized=normalizeMovement(item); return { ...normalized, categoryId: typeof item.categoryId==='string' && item.categoryId ? item.categoryId : null }; } function normalizeCategorizedMovement(item={}) { const normalized=normalizeMovement(item); return { ...normalized, categoryId: typeof item.categoryId==='string' && item.categoryId ? item.categoryId : null }; }
function normalizeExpense(item={}) { return { ...normalizeCategorizedMovement(item), pushoverReminder: item.pushoverReminder===true }; }
function normalizeScenarioAdjustment(item={}) { function normalizeScenarioAdjustment(item={}) {
const type=validScenarioTypes.has(item.type)?item.type:'expense'; const type=validScenarioTypes.has(item.type)?item.type:'expense';
const base={id:item.id,name:item.name,type,amount:Number(item.amount||0)}; const base={id:item.id,name:item.name,type,amount:Number(item.amount||0)};
@@ -23,12 +26,12 @@ function normalizeScenario(item={}) { return {id:item.id,name:item.name,createdA
function normalizeData(input){ function normalizeData(input){
if(!input||typeof input!=='object')return input; if(!input||typeof input!=='object')return input;
return { return {
version:6, version:7,
settings:{includePeriodicInBalance:input.settings?.includePeriodicInBalance!==false}, settings:{includePeriodicInBalance:input.settings?.includePeriodicInBalance!==false},
accounts:Array.isArray(input.accounts)?input.accounts.map(({id,name})=>({id,name})):input.accounts, accounts:Array.isArray(input.accounts)?input.accounts.map(({id,name})=>({id,name})):input.accounts,
categories:Array.isArray(input.categories)?input.categories.map(({id,name})=>({id,name})):[], categories:Array.isArray(input.categories)?input.categories.map(({id,name})=>({id,name})):[],
incomes:Array.isArray(input.incomes)?input.incomes.map(normalizeCategorizedMovement):input.incomes, incomes:Array.isArray(input.incomes)?input.incomes.map(normalizeCategorizedMovement):input.incomes,
expenses:Array.isArray(input.expenses)?input.expenses.map(normalizeCategorizedMovement):input.expenses, expenses:Array.isArray(input.expenses)?input.expenses.map(normalizeExpense):input.expenses,
transfers:Array.isArray(input.transfers)?input.transfers.map(normalizeCategorizedMovement):input.transfers, transfers:Array.isArray(input.transfers)?input.transfers.map(normalizeCategorizedMovement):input.transfers,
scenarios:Array.isArray(input.scenarios)?input.scenarios.map(normalizeScenario):[], scenarios:Array.isArray(input.scenarios)?input.scenarios.map(normalizeScenario):[],
}; };
@@ -41,7 +44,7 @@ function validateData(input){
for(const a of input.accounts){if(!a.id||typeof a.id!=='string')throw new Error('Konto ohne gültige ID.');if(!a.name||typeof a.name!=='string')throw new Error('Konto ohne Namen.');if(accountIds.has(a.id))throw new Error('Doppelte Konto-ID.');accountIds.add(a.id);} for(const a of input.accounts){if(!a.id||typeof a.id!=='string')throw new Error('Konto ohne gültige ID.');if(!a.name||typeof a.name!=='string')throw new Error('Konto ohne Namen.');if(accountIds.has(a.id))throw new Error('Doppelte Konto-ID.');accountIds.add(a.id);}
const categoryIds=new Set(); const categoryNames=new Set(); const categoryIds=new Set(); const categoryNames=new Set();
for(const c of input.categories){if(!c.id||typeof c.id!=='string')throw new Error('Kategorie ohne gültige ID.');if(!c.name||typeof c.name!=='string'||!c.name.trim())throw new Error('Kategorie ohne Namen.');if(categoryIds.has(c.id))throw new Error('Doppelte Kategorie-ID.');const normalizedName=c.name.trim().toLocaleLowerCase('de-DE');if(categoryNames.has(normalizedName))throw new Error(`Kategorie '${c.name}' existiert bereits.`);categoryIds.add(c.id);categoryNames.add(normalizedName);} for(const c of input.categories){if(!c.id||typeof c.id!=='string')throw new Error('Kategorie ohne gültige ID.');if(!c.name||typeof c.name!=='string'||!c.name.trim())throw new Error('Kategorie ohne Namen.');if(categoryIds.has(c.id))throw new Error('Doppelte Kategorie-ID.');const normalizedName=c.name.trim().toLocaleLowerCase('de-DE');if(categoryNames.has(normalizedName))throw new Error(`Kategorie '${c.name}' existiert bereits.`);categoryIds.add(c.id);categoryNames.add(normalizedName);}
for(const collection of ['incomes','expenses'])for(const item of input[collection]){validateMovement(item);if(!accountIds.has(item.accountId))throw new Error(`Unbekanntes Konto bei '${item.name}'.`);if(item.categoryId!==null&&item.categoryId!==undefined&&!categoryIds.has(item.categoryId))throw new Error(`Unbekannte Kategorie bei '${item.name}'.`);} for(const collection of ['incomes','expenses'])for(const item of input[collection]){validateMovement(item);if(!accountIds.has(item.accountId))throw new Error(`Unbekanntes Konto bei '${item.name}'.`);if(item.categoryId!==null&&item.categoryId!==undefined&&!categoryIds.has(item.categoryId))throw new Error(`Unbekannte Kategorie bei '${item.name}'.`);if(collection==='expenses'&&typeof item.pushoverReminder!=='boolean')throw new Error(`Ungültige Reminder-Einstellung bei '${item.name}'.`);if(collection==='expenses'&&item.pushoverReminder&&item.interval!=='monthly'&&(item.dueMonth===null||item.dueMonth===undefined))throw new Error(`Für den Pushover-Reminder von '${item.name}' muss ein Fälligkeitsmonat gesetzt sein.`);}
for(const t of input.transfers){validateMovement(t);if(!accountIds.has(t.fromAccountId)||!accountIds.has(t.toAccountId))throw new Error(`Unbekanntes Konto beim Transfer '${t.name}'.`);if(t.fromAccountId===t.toAccountId)throw new Error('Quell- und Zielkonto müssen verschieden sein.');if(t.categoryId!==null&&t.categoryId!==undefined&&!categoryIds.has(t.categoryId))throw new Error(`Unbekannte Kategorie beim Transfer '${t.name}'.`);} for(const t of input.transfers){validateMovement(t);if(!accountIds.has(t.fromAccountId)||!accountIds.has(t.toAccountId))throw new Error(`Unbekanntes Konto beim Transfer '${t.name}'.`);if(t.fromAccountId===t.toAccountId)throw new Error('Quell- und Zielkonto müssen verschieden sein.');if(t.categoryId!==null&&t.categoryId!==undefined&&!categoryIds.has(t.categoryId))throw new Error(`Unbekannte Kategorie beim Transfer '${t.name}'.`);}
const scenarioIds=new Set(); const scenarioIds=new Set();
for(const s of input.scenarios){ for(const s of input.scenarios){
@@ -60,8 +63,47 @@ function validateData(input){
} }
} }
} }
const pushoverKeyPattern=/^[A-Za-z0-9]{30}$/;
function maskKey(value=''){return value?`${value.slice(0,4)}••••••${value.slice(-4)}`:'';}
async function readJsonFile(file,fallback){try{return JSON.parse(await fs.readFile(file,'utf8'));}catch(error){if(error.code==='ENOENT')return fallback;throw error;}}
async function writePrivateJson(file,value){await fs.mkdir(path.dirname(file),{recursive:true});const tmp=`${file}.${process.pid}.${crypto.randomUUID()}.tmp`;await fs.writeFile(tmp,JSON.stringify(value,null,2)+'\n',{encoding:'utf8',mode:0o600});await fs.rename(tmp,file);}
async function readPushoverConfig(){const value=await readJsonFile(pushoverFile,null);if(!value||!pushoverKeyPattern.test(value.token||'')||!pushoverKeyPattern.test(value.user||''))return null;return {token:value.token,user:value.user};}
async function savePushoverConfig(input){const token=String(input?.token||'').trim();const user=String(input?.user||'').trim();if(!pushoverKeyPattern.test(token))throw new Error('Der Pushover Application API Token muss 30 alphanumerische Zeichen enthalten.');if(!pushoverKeyPattern.test(user))throw new Error('Der Pushover User/Group Key muss 30 alphanumerische Zeichen enthalten.');await writePrivateJson(pushoverFile,{token,user,updatedAt:new Date().toISOString()});return {configured:true,userMasked:maskKey(user)};}
async function sendPushover(config,{title,message}){
const body=new URLSearchParams({token:config.token,user:config.user,title,message});
const response=await fetch(PUSHOVER_ENDPOINT,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded','User-Agent':'FixFin/3.15'},body});
let payload={}; try{payload=await response.json();}catch{}
if(!response.ok||payload.status!==1)throw new Error(Array.isArray(payload.errors)?payload.errors.join(' '):`Pushover-API Fehler (${response.status})`);
return payload;
}
function occursInReminderMonth(item,monthIndex){
const interval=item.interval||'monthly'; if(interval==='monthly')return true;
const dueMonth=Number(item.dueMonth); if(!Number.isInteger(dueMonth)||dueMonth<1||dueMonth>12)return false;
const start=dueMonth-1; const step=interval==='quarterly'?3:interval==='semiannual'?6:12;
return monthIndex>=start&&(monthIndex-start)%step===0;
}
function reminderMessage(data,expense,now){
const account=data.accounts.find(a=>a.id===expense.accountId)?.name||'Unbekanntes Konto';
const amount=new Intl.NumberFormat('de-DE',{style:'currency',currency:'EUR'}).format(Number(expense.amount||0));
const month=new Intl.DateTimeFormat('de-DE',{month:'long'}).format(now);
return {title:`FixFin · ${expense.name}`,message:`Ausgabe im ${month}: ${amount}\nKonto: ${account}\nBitte prüfen, ob eine Umbuchung nötig ist.`};
}
async function runPushoverReminderCheck(){
const now=new Date(); if(now.getDate()!==1||now.getHours()<reminderHour)return;
const config=await readPushoverConfig(); if(!config)return;
const data=await readData(); const state=await readJsonFile(pushoverStateFile,{sent:{}}); if(!state.sent||typeof state.sent!=='object')state.sent={};
const monthKey=`${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}`; let changed=false;
for(const expense of data.expenses.filter(x=>x.pushoverReminder===true&&occursInReminderMonth(x,now.getMonth()))){
const key=`${monthKey}:${expense.id}`; if(state.sent[key])continue;
try{await sendPushover(config,reminderMessage(data,expense,now));state.sent[key]=new Date().toISOString();changed=true;console.log(`[Pushover] Reminder gesendet: ${expense.name}`);}catch(error){console.error(`[Pushover] Reminder fehlgeschlagen (${expense.name}):`,error.message);}
}
const keepAfter=new Date(now.getFullYear()-2,now.getMonth(),1); for(const key of Object.keys(state.sent)){const ym=key.slice(0,7);const [y,m]=ym.split('-').map(Number);if(Number.isInteger(y)&&Number.isInteger(m)&&new Date(y,m-1,1)<keepAfter){delete state.sent[key];changed=true;}}
if(changed)await writePrivateJson(pushoverStateFile,state);
}
async function ensureDataFile(){await fs.mkdir(dataDir,{recursive:true});try{await fs.access(dataFile);}catch{await atomicWrite(emptyData(),false);}} async function ensureDataFile(){await fs.mkdir(dataDir,{recursive:true});try{await fs.access(dataFile);}catch{await atomicWrite(emptyData(),false);}}
async function readData(){await ensureDataFile();const raw=await fs.readFile(dataFile,'utf8');const parsed=JSON.parse(raw);const data=normalizeData(parsed);validateData(data);const movements=[...(parsed.incomes||[]),...(parsed.expenses||[]),...(parsed.transfers||[])];if(parsed.version!==6||!parsed.settings||!Array.isArray(parsed.categories)||!Array.isArray(parsed.scenarios)||movements.some(x=>!x.interval)||movements.some(x=>!Object.hasOwn(x,'categoryId'))||parsed.accounts?.some(a=>Object.hasOwn(a,'balance')))await atomicWrite(data,true);return data;} async function readData(){await ensureDataFile();const raw=await fs.readFile(dataFile,'utf8');const parsed=JSON.parse(raw);const data=normalizeData(parsed);validateData(data);const movements=[...(parsed.incomes||[]),...(parsed.expenses||[]),...(parsed.transfers||[])];if(parsed.version!==7||!parsed.settings||!Array.isArray(parsed.categories)||!Array.isArray(parsed.scenarios)||movements.some(x=>!x.interval)||movements.some(x=>!Object.hasOwn(x,'categoryId'))||(parsed.expenses||[]).some(x=>!Object.hasOwn(x,'pushoverReminder'))||parsed.accounts?.some(a=>Object.hasOwn(a,'balance')))await atomicWrite(data,true);return data;}
async function atomicWrite(input,makeBackup=true){const data=normalizeData(input);validateData(data);await fs.mkdir(dataDir,{recursive:true});const tmp=`${dataFile}.${process.pid}.${crypto.randomUUID()}.tmp`;const payload=JSON.stringify(data,null,2)+'\n';if(makeBackup){try{await fs.copyFile(dataFile,backupFile);}catch(error){if(error.code!=='ENOENT')throw error;}}await fs.writeFile(tmp,payload,{encoding:'utf8',mode:0o600});await fs.rename(tmp,dataFile);} async function atomicWrite(input,makeBackup=true){const data=normalizeData(input);validateData(data);await fs.mkdir(dataDir,{recursive:true});const tmp=`${dataFile}.${process.pid}.${crypto.randomUUID()}.tmp`;const payload=JSON.stringify(data,null,2)+'\n';if(makeBackup){try{await fs.copyFile(dataFile,backupFile);}catch(error){if(error.code!=='ENOENT')throw error;}}await fs.writeFile(tmp,payload,{encoding:'utf8',mode:0o600});await fs.rename(tmp,dataFile);}
app.disable('x-powered-by');app.use(express.json({limit:'1mb'}));app.get('/api/health',(_req,res)=>res.json({ok:true}));app.get('/api/data',async(_req,res,next)=>{try{res.json(await readData());}catch(e){next(e);}});app.put('/api/data',async(req,res,next)=>{try{await atomicWrite(req.body);res.json(await readData());}catch(e){next(e);}});app.get('/api/export',async(_req,res,next)=>{try{const data=await readData();res.setHeader('Content-Disposition','attachment; filename="fixfin-backup.json"');res.type('application/json').send(JSON.stringify(data,null,2)+'\n');}catch(e){next(e);}}); app.disable('x-powered-by');app.use(express.json({limit:'1mb'}));app.get('/api/health',(_req,res)=>res.json({ok:true}));app.get('/api/data',async(_req,res,next)=>{try{res.json(await readData());}catch(e){next(e);}});app.put('/api/data',async(req,res,next)=>{try{await atomicWrite(req.body);res.json(await readData());}catch(e){next(e);}});app.get('/api/export',async(_req,res,next)=>{try{const data=await readData();res.setHeader('Content-Disposition','attachment; filename="fixfin-backup.json"');res.type('application/json').send(JSON.stringify(data,null,2)+'\n');}catch(e){next(e);}});app.get('/api/pushover/config',async(_req,res,next)=>{try{const config=await readPushoverConfig();res.json(config?{configured:true,userMasked:maskKey(config.user)}:{configured:false,userMasked:''});}catch(e){next(e);}});app.put('/api/pushover/config',async(req,res,next)=>{try{res.json(await savePushoverConfig(req.body));}catch(e){next(e);}});app.delete('/api/pushover/config',async(_req,res,next)=>{try{await fs.rm(pushoverFile,{force:true});res.json({configured:false,userMasked:''});}catch(e){next(e);}});app.post('/api/pushover/test',async(_req,res,next)=>{try{const config=await readPushoverConfig();if(!config)throw new Error('Pushover ist noch nicht konfiguriert.');await sendPushover(config,{title:'FixFin · Test',message:'Pushover ist erfolgreich mit FixFin verbunden.'});res.json({ok:true});}catch(e){next(e);}});
const dist=path.resolve(__dirname,'../dist');app.use(express.static(dist));app.use((_req,res)=>res.sendFile(path.join(dist,'index.html')));app.use((error,_req,res,_next)=>{console.error(error);res.status(400).json({error:error.message||'Unbekannter Fehler'});});await ensureDataFile();app.listen(port,'0.0.0.0',()=>{console.log(`FixFin läuft auf Port ${port}`);console.log(`Datendatei: ${dataFile}`);}); const dist=path.resolve(__dirname,'../dist');app.use(express.static(dist));app.use((_req,res)=>res.sendFile(path.join(dist,'index.html')));app.use((error,_req,res,_next)=>{console.error(error);res.status(400).json({error:error.message||'Unbekannter Fehler'});});await ensureDataFile();app.listen(port,'0.0.0.0',()=>{console.log(`FixFin läuft auf Port ${port}`);console.log(`Datendatei: ${dataFile}`);console.log(`Pushover-Reminder: Prüfung am 1. des Monats ab ${String(reminderHour).padStart(2,'0')}:00 Uhr (${process.env.TZ||'System-Zeitzone'})`);});setTimeout(()=>runPushoverReminderCheck().catch(error=>console.error('[Pushover] Reminder-Prüfung fehlgeschlagen:',error)),5000);setInterval(()=>runPushoverReminderCheck().catch(error=>console.error('[Pushover] Reminder-Prüfung fehlgeschlagen:',error)),15*60*1000);
+24 -5
View File
@@ -10,7 +10,7 @@ const intervals = {
semiannual: { label: 'Halbjährlich', divisor: 6, step: 6 }, semiannual: { label: 'Halbjährlich', divisor: 6, step: 6 },
yearly: { label: 'Jährlich', divisor: 12, step: 12 }, yearly: { label: 'Jährlich', divisor: 12, step: 12 },
}; };
const emptyData = { version: 6, settings: { includePeriodicInBalance: true }, accounts: [], categories: [], incomes: [], expenses: [], transfers: [], scenarios: [] }; const emptyData = { version: 7, settings: { includePeriodicInBalance: true }, accounts: [], categories: [], incomes: [], expenses: [], transfers: [], scenarios: [] };
function formatMoney(value) { return euro.format(Number(value || 0)); } function formatMoney(value) { return euro.format(Number(value || 0)); }
function parseAmount(value) { function parseAmount(value) {
@@ -335,9 +335,9 @@ function Entries({type,items,accounts,categories,onEdit,onDuplicate,onDelete}){
const isIncome=type==='incomes'; const isIncome=type==='incomes';
const dueText=x=>x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'Nicht festgelegt'; const dueText=x=>x.interval==='monthly'?'Jeden Monat':x.dueMonth?months[Number(x.dueMonth)-1]:'Nicht festgelegt';
return <section className="panel movement-list-panel"><div className="section-head"><div><h2>{isIncome?'Fixe Eingänge':'Fixe Ausgänge'}</h2><p>Betrag gilt pro gewähltem Intervall. Periodische Werte werden zusätzlich als Monatsanteil gezeigt.</p></div></div>{items.length===0?<Empty text={`Noch keine ${isIncome?'Eingänge':'Ausgänge'} vorhanden.`}/>:<> return <section className="panel movement-list-panel"><div className="section-head"><div><h2>{isIncome?'Fixe Eingänge':'Fixe Ausgänge'}</h2><p>Betrag gilt pro gewähltem Intervall. Periodische Werte werden zusätzlich als Monatsanteil gezeigt.</p></div></div>{items.length===0?<Empty text={`Noch keine ${isIncome?'Eingänge':'Ausgänge'} vorhanden.`}/>:<>
<div className="table-wrap movement-desktop-table"><table><thead><tr><th>Bezeichnung</th><th>Kategorie</th><th>Konto</th><th>Intervall</th><th>Monat</th><th className="number">Betrag</th><th className="number">Ø / Monat</th><th></th></tr></thead><tbody>{items.map(x=><tr key={x.id}><td><strong>{x.name}</strong></td><td><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span></td><td>{accountNameLocal(x.accountId)}</td><td><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span></td><td>{dueText(x)}</td><td className={`number ${isIncome?'good-text':'bad-text'}`}>{isIncome?'+':''}{formatMoney(x.amount)}</td><td className="number">{formatMoney(monthlyEquivalent(x))}</td><td className="actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete(type,x.id)}>Löschen</button></td></tr>)}</tbody></table></div> <div className="table-wrap movement-desktop-table"><table><thead><tr><th>Bezeichnung</th><th>Kategorie</th><th>Konto</th><th>Intervall</th><th>Monat</th><th className="number">Betrag</th><th className="number">Ø / Monat</th><th></th></tr></thead><tbody>{items.map(x=><tr key={x.id}><td><strong>{x.name}</strong>{!isIncome&&x.pushoverReminder&&<span className="reminder-badge">Reminder</span>}</td><td><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span></td><td>{accountNameLocal(x.accountId)}</td><td><span className="interval-badge">{intervals[x.interval||'monthly'].label}</span></td><td>{dueText(x)}</td><td className={`number ${isIncome?'good-text':'bad-text'}`}>{isIncome?'+':''}{formatMoney(x.amount)}</td><td className="number">{formatMoney(monthlyEquivalent(x))}</td><td className="actions"><button onClick={()=>onDuplicate(x)}>Duplizieren</button><button onClick={()=>onEdit(x)}>Bearbeiten</button><button className="danger" onClick={()=>onDelete(type,x.id)}>Löschen</button></td></tr>)}</tbody></table></div>
<div className="movement-mobile-list">{items.map(x=><article className="movement-card" key={x.id}> <div className="movement-mobile-list">{items.map(x=><article className="movement-card" key={x.id}>
<div className="movement-card-head"><div><strong>{x.name}</strong><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span></div><strong className={isIncome?'good-text':'bad-text'}>{isIncome?'+':''}{formatMoney(x.amount)}</strong></div> <div className="movement-card-head"><div><strong>{x.name}</strong><span className={`category-badge ${x.categoryId?'':'muted-category'}`}>{categoryNameLocal(x.categoryId)}</span>{!isIncome&&x.pushoverReminder&&<span className="reminder-badge">Reminder am 1.</span>}</div><strong className={isIncome?'good-text':'bad-text'}>{isIncome?'+':''}{formatMoney(x.amount)}</strong></div>
<div className="movement-card-grid"> <div className="movement-card-grid">
<div><span>Konto</span><strong>{accountNameLocal(x.accountId)}</strong></div> <div><span>Konto</span><strong>{accountNameLocal(x.accountId)}</strong></div>
<div><span>Intervall</span><strong>{intervals[x.interval||'monthly'].label}</strong></div> <div><span>Intervall</span><strong>{intervals[x.interval||'monthly'].label}</strong></div>
@@ -493,6 +493,22 @@ function ScenarioAdjustmentDialog({data,item,onClose,onSave}){
return <div className="modal-backdrop" onMouseDown={e=>e.target===e.currentTarget&&onClose()}><form className="modal" onSubmit={submit}><div className="modal-head"><div><span className="eyebrow">SZENARIO</span><h2>{item?'Anpassung bearbeiten':'Anpassung hinzufügen'}</h2></div><button type="button" className="close" onClick={onClose}>×</button></div>{error&&<div className="alert">{error}</div>}<label><span>Bezeichnung</span><input autoFocus value={name} onChange={e=>setName(e.target.value)} placeholder="z. B. Neue Wohnung"/></label><label><span>Art</span><select value={type} onChange={e=>setType(e.target.value)}><option value="income">Eingang</option><option value="expense">Ausgang</option><option value="transfer">Transfer</option></select></label><label><span>Betrag / Monat</span><div className="money-input"><input inputMode="decimal" value={amount} onChange={e=>setAmount(e.target.value)} placeholder="0,00"/><span></span></div></label>{type!=='transfer'?<label><span>Konto</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label>:<><label><span>Von Konto</span><select value={fromAccountId} onChange={e=>setFromAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label><label><span>Nach Konto</span><select value={toAccountId} onChange={e=>setToAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label></>}<div className="modal-actions"><button type="button" className="secondary" onClick={onClose}>Abbrechen</button><button className="primary" type="submit">Speichern</button></div></form></div>; return <div className="modal-backdrop" onMouseDown={e=>e.target===e.currentTarget&&onClose()}><form className="modal" onSubmit={submit}><div className="modal-head"><div><span className="eyebrow">SZENARIO</span><h2>{item?'Anpassung bearbeiten':'Anpassung hinzufügen'}</h2></div><button type="button" className="close" onClick={onClose}>×</button></div>{error&&<div className="alert">{error}</div>}<label><span>Bezeichnung</span><input autoFocus value={name} onChange={e=>setName(e.target.value)} placeholder="z. B. Neue Wohnung"/></label><label><span>Art</span><select value={type} onChange={e=>setType(e.target.value)}><option value="income">Eingang</option><option value="expense">Ausgang</option><option value="transfer">Transfer</option></select></label><label><span>Betrag / Monat</span><div className="money-input"><input inputMode="decimal" value={amount} onChange={e=>setAmount(e.target.value)} placeholder="0,00"/><span></span></div></label>{type!=='transfer'?<label><span>Konto</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label>:<><label><span>Von Konto</span><select value={fromAccountId} onChange={e=>setFromAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label><label><span>Nach Konto</span><select value={toAccountId} onChange={e=>setToAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label></>}<div className="modal-actions"><button type="button" className="secondary" onClick={onClose}>Abbrechen</button><button className="primary" type="submit">Speichern</button></div></form></div>;
} }
function PushoverSettings(){
const [status,setStatus]=useState({loading:true,configured:false,userMasked:''});
const [user,setUser]=useState(''); const [token,setToken]=useState(''); const [busy,setBusy]=useState(false); const [message,setMessage]=useState(''); const [error,setError]=useState('');
async function load(){try{const r=await fetch('/api/pushover/config');const body=await r.json();if(!r.ok)throw new Error(body.error||'Pushover-Status konnte nicht geladen werden.');setStatus({...body,loading:false});}catch(e){setError(e.message);setStatus(x=>({...x,loading:false}));}}
useEffect(()=>{load();},[]);
async function save(e){e.preventDefault();setBusy(true);setError('');setMessage('');try{const r=await fetch('/api/pushover/config',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({user:user.trim(),token:token.trim()})});const body=await r.json();if(!r.ok)throw new Error(body.error||'Pushover konnte nicht gespeichert werden.');setStatus({...body,loading:false});setUser('');setToken('');setMessage('Zugangsdaten gespeichert.');}catch(e){setError(e.message);}finally{setBusy(false);}}
async function test(){setBusy(true);setError('');setMessage('');try{const r=await fetch('/api/pushover/test',{method:'POST'});const body=await r.json();if(!r.ok)throw new Error(body.error||'Testnachricht fehlgeschlagen.');setMessage('Testnachricht wurde gesendet.');}catch(e){setError(e.message);}finally{setBusy(false);}}
async function remove(){if(!confirm('Pushover-Zugangsdaten wirklich entfernen?'))return;setBusy(true);setError('');setMessage('');try{const r=await fetch('/api/pushover/config',{method:'DELETE'});const body=await r.json();if(!r.ok)throw new Error(body.error||'Zugangsdaten konnten nicht entfernt werden.');setStatus({...body,loading:false});setMessage('Pushover-Zugangsdaten entfernt.');}catch(e){setError(e.message);}finally{setBusy(false);}}
return <section className="panel pushover-settings"><div className="section-head"><div><h2>Pushover</h2><p>Reminder für markierte Ausgaben werden am 1. ihres Fälligkeitsmonats serverseitig verschickt.</p></div><span className={`pushover-status ${status.configured?'configured':''}`}>{status.loading?'Prüft …':status.configured?'Konfiguriert':'Nicht konfiguriert'}</span></div>
{status.configured&&<div className="pushover-current"><span>User/Group Key</span><strong>{status.userMasked}</strong></div>}
{error&&<div className="alert compact-alert">{error}</div>}{message&&<div className="success-note">{message}</div>}
<form className="pushover-form" onSubmit={save}><label><span>User / Group Key</span><input autoComplete="off" value={user} onChange={e=>setUser(e.target.value)} placeholder={status.configured?'Zum Ändern neu eingeben':'30-stelliger User/Group Key'}/></label><label><span>Application API Token</span><input type="password" autoComplete="new-password" value={token} onChange={e=>setToken(e.target.value)} placeholder={status.configured?'Zum Ändern neu eingeben':'30-stelliger API Token'}/></label><div className="button-row pushover-actions"><button className="primary" type="submit" disabled={busy}>{status.configured?'Zugangsdaten ändern':'Speichern'}</button>{status.configured&&<><button className="secondary" type="button" disabled={busy} onClick={test}>Test senden</button><button className="danger" type="button" disabled={busy} onClick={remove}>Entfernen</button></>}</div></form>
<p className="pushover-help">Die Zugangsdaten werden separat serverseitig gespeichert und sind weder Teil des JSON-Exports noch der PWA-Daten.</p>
</section>;
}
function DataPage({data,importFile}){ function DataPage({data,importFile}){
const summary=[ const summary=[
['Konten',data.accounts.length], ['Konten',data.accounts.length],
@@ -504,6 +520,7 @@ function DataPage({data,importFile}){
]; ];
const json=JSON.stringify(data,null,2); const json=JSON.stringify(data,null,2);
return <div className="content-stack data-page"> return <div className="content-stack data-page">
<PushoverSettings/>
<section className="panel data-backup-panel"><div className="section-head"><div><h2>Daten sichern</h2><p>Alle Daten liegen in einer JSON-Datei. Vor jedem Speichern wird serverseitig automatisch eine <code>.bak</code>-Datei angelegt.</p></div></div><div className="button-row data-actions"><a className="primary button-link" href="/api/export">JSON herunterladen</a><label className="secondary button-link">JSON importieren<input type="file" accept="application/json,.json" hidden onChange={e=>e.target.files?.[0]&&importFile(e.target.files[0])}/></label></div></section> <section className="panel data-backup-panel"><div className="section-head"><div><h2>Daten sichern</h2><p>Alle Daten liegen in einer JSON-Datei. Vor jedem Speichern wird serverseitig automatisch eine <code>.bak</code>-Datei angelegt.</p></div></div><div className="button-row data-actions"><a className="primary button-link" href="/api/export">JSON herunterladen</a><label className="secondary button-link">JSON importieren<input type="file" accept="application/json,.json" hidden onChange={e=>e.target.files?.[0]&&importFile(e.target.files[0])}/></label></div></section>
<section className="panel data-status-panel"><div className="section-head"><div><h2>Aktueller Datenstand</h2><p>Kurzübersicht über den Inhalt deiner FixFin-Datei.</p></div></div> <section className="panel data-status-panel"><div className="section-head"><div><h2>Aktueller Datenstand</h2><p>Kurzübersicht über den Inhalt deiner FixFin-Datei.</p></div></div>
<div className="data-summary-grid">{summary.map(([label,value])=><div className="data-summary-card" key={label}><span>{label}</span><strong>{Number(value).toLocaleString('de-DE')}</strong></div>)}</div> <div className="data-summary-grid">{summary.map(([label,value])=><div className="data-summary-card" key={label}><span>{label}</span><strong>{Number(value).toLocaleString('de-DE')}</strong></div>)}</div>
@@ -516,7 +533,7 @@ function Empty({text}){return <div className="empty">{text}</div>}
function EditorDialog({dialog,data,onClose,onSave}){ function EditorDialog({dialog,data,onClose,onSave}){
const type=dialog.type,item=dialog.item; const editing=Boolean(item?.id); const type=dialog.type,item=dialog.item; const editing=Boolean(item?.id);
const [name,setName]=useState(item?.name||''),[amount,setAmount]=useState(item?.amount??''),[accountId,setAccountId]=useState(item?.accountId||data.accounts[0]?.id||''),[fromAccountId,setFromAccountId]=useState(item?.fromAccountId||data.accounts[0]?.id||''),[toAccountId,setToAccountId]=useState(item?.toAccountId||data.accounts[1]?.id||data.accounts[0]?.id||''),[interval,setInterval]=useState(item?.interval||'monthly'),[dueMonth,setDueMonth]=useState(item?.dueMonth?String(item.dueMonth):''),[categoryId,setCategoryId]=useState(item?.categoryId||''),[newCategoryName,setNewCategoryName]=useState(''),[localError,setLocalError]=useState(''); const [name,setName]=useState(item?.name||''),[amount,setAmount]=useState(item?.amount??''),[accountId,setAccountId]=useState(item?.accountId||data.accounts[0]?.id||''),[fromAccountId,setFromAccountId]=useState(item?.fromAccountId||data.accounts[0]?.id||''),[toAccountId,setToAccountId]=useState(item?.toAccountId||data.accounts[1]?.id||data.accounts[0]?.id||''),[interval,setInterval]=useState(item?.interval||'monthly'),[dueMonth,setDueMonth]=useState(item?.dueMonth?String(item.dueMonth):''),[categoryId,setCategoryId]=useState(item?.categoryId||''),[newCategoryName,setNewCategoryName]=useState(''),[pushoverReminder,setPushoverReminder]=useState(item?.pushoverReminder===true),[localError,setLocalError]=useState('');
const labels={accounts:'Konto',categories:'Kategorie',incomes:'Eingang',expenses:'Ausgang',transfers:'Transfer'}; const labels={accounts:'Konto',categories:'Kategorie',incomes:'Eingang',expenses:'Ausgang',transfers:'Transfer'};
async function submit(e){ async function submit(e){
e.preventDefault();setLocalError(''); const numeric=parseAmount(amount); e.preventDefault();setLocalError(''); const numeric=parseAmount(amount);
@@ -524,6 +541,7 @@ function EditorDialog({dialog,data,onClose,onSave}){
if(type!=='accounts'&&type!=='categories'&&(numeric<0||!Number.isFinite(numeric)))return setLocalError('Bitte einen gültigen Betrag eingeben.'); if(type!=='accounts'&&type!=='categories'&&(numeric<0||!Number.isFinite(numeric)))return setLocalError('Bitte einen gültigen Betrag eingeben.');
if(!['accounts','categories'].includes(type)&&data.accounts.length===0)return setLocalError('Bitte zuerst ein Konto anlegen.'); if(!['accounts','categories'].includes(type)&&data.accounts.length===0)return setLocalError('Bitte zuerst ein Konto anlegen.');
if(type==='transfers'&&fromAccountId===toAccountId)return setLocalError('Quell- und Zielkonto müssen verschieden sein.'); if(type==='transfers'&&fromAccountId===toAccountId)return setLocalError('Quell- und Zielkonto müssen verschieden sein.');
if(type==='expenses'&&pushoverReminder&&interval!=='monthly'&&!dueMonth)return setLocalError('Für den Reminder bitte einen Fälligkeitsmonat festlegen.');
let next=structuredClone(data); const id=editing?item.id:uid(); let value; let next=structuredClone(data); const id=editing?item.id:uid(); let value;
if(type==='accounts') value={id,name:name.trim()}; if(type==='accounts') value={id,name:name.trim()};
else if(type==='categories') { const duplicate=next.categories.some(c=>c.id!==item?.id&&c.name.trim().toLocaleLowerCase('de-DE')===name.trim().toLocaleLowerCase('de-DE')); if(duplicate)return setLocalError('Diese Kategorie existiert bereits.'); value={id,name:name.trim()}; } else if(type==='categories') { const duplicate=next.categories.some(c=>c.id!==item?.id&&c.name.trim().toLocaleLowerCase('de-DE')===name.trim().toLocaleLowerCase('de-DE')); if(duplicate)return setLocalError('Diese Kategorie existiert bereits.'); value={id,name:name.trim()}; }
@@ -531,7 +549,7 @@ function EditorDialog({dialog,data,onClose,onSave}){
const common={id,name:name.trim(),amount:numeric,interval,dueMonth:interval==='monthly'?null:(dueMonth?Number(dueMonth):null)}; const common={id,name:name.trim(),amount:numeric,interval,dueMonth:interval==='monthly'?null:(dueMonth?Number(dueMonth):null)};
let selectedCategoryId=categoryId||null; let selectedCategoryId=categoryId||null;
if(categoryId==='__new__'){const categoryNameValue=newCategoryName.trim();if(!categoryNameValue)return setLocalError('Bitte einen Namen für die neue Kategorie eingeben.');const existing=next.categories.find(c=>c.name.trim().toLocaleLowerCase('de-DE')===categoryNameValue.toLocaleLowerCase('de-DE'));if(existing) selectedCategoryId=existing.id;else { selectedCategoryId=uid(); next.categories.push({id:selectedCategoryId,name:categoryNameValue}); }} if(categoryId==='__new__'){const categoryNameValue=newCategoryName.trim();if(!categoryNameValue)return setLocalError('Bitte einen Namen für die neue Kategorie eingeben.');const existing=next.categories.find(c=>c.name.trim().toLocaleLowerCase('de-DE')===categoryNameValue.toLocaleLowerCase('de-DE'));if(existing) selectedCategoryId=existing.id;else { selectedCategoryId=uid(); next.categories.push({id:selectedCategoryId,name:categoryNameValue}); }}
if(type==='transfers') value={...common,fromAccountId,toAccountId,categoryId:selectedCategoryId}; else value={...common,accountId,categoryId:selectedCategoryId}; if(type==='transfers') value={...common,fromAccountId,toAccountId,categoryId:selectedCategoryId}; else if(type==='expenses') value={...common,accountId,categoryId:selectedCategoryId,pushoverReminder}; else value={...common,accountId,categoryId:selectedCategoryId};
} }
next[type]=editing?next[type].map(x=>x.id===item.id?value:x):[...next[type],value]; next[type]=editing?next[type].map(x=>x.id===item.id?value:x):[...next[type],value];
try{await onSave(next);}catch(e){setLocalError(e.message);} try{await onSave(next);}catch(e){setLocalError(e.message);}
@@ -541,6 +559,7 @@ function EditorDialog({dialog,data,onClose,onSave}){
{!simpleType&&<><label><span>Betrag pro Intervall</span><div className="money-input"><input inputMode="decimal" value={amount} onChange={e=>setAmount(e.target.value)} placeholder="0,00"/><span></span></div></label><label><span>Intervall</span><select value={interval} onChange={e=>setInterval(e.target.value)}>{Object.entries(intervals).map(([key,x])=><option key={key} value={key}>{x.label}</option>)}</select></label>{interval!=='monthly'&&<label><span>{interval==='yearly'?'Monat der Fälligkeit (optional)':'Erster Fälligkeitsmonat (optional)'}</span><select value={dueMonth} onChange={e=>setDueMonth(e.target.value)}><option value="">Nicht festgelegt</option>{months.map((m,i)=><option key={m} value={i+1}>{m}</option>)}</select><small className="field-help">{interval==='quarterly'?'Ab diesem Monat alle 3 Monate.':interval==='semiannual'?'Ab diesem Monat alle 6 Monate.':'Für die Jahresprognose.'}</small></label>}</>} {!simpleType&&<><label><span>Betrag pro Intervall</span><div className="money-input"><input inputMode="decimal" value={amount} onChange={e=>setAmount(e.target.value)} placeholder="0,00"/><span></span></div></label><label><span>Intervall</span><select value={interval} onChange={e=>setInterval(e.target.value)}>{Object.entries(intervals).map(([key,x])=><option key={key} value={key}>{x.label}</option>)}</select></label>{interval!=='monthly'&&<label><span>{interval==='yearly'?'Monat der Fälligkeit (optional)':'Erster Fälligkeitsmonat (optional)'}</span><select value={dueMonth} onChange={e=>setDueMonth(e.target.value)}><option value="">Nicht festgelegt</option>{months.map((m,i)=><option key={m} value={i+1}>{m}</option>)}</select><small className="field-help">{interval==='quarterly'?'Ab diesem Monat alle 3 Monate.':interval==='semiannual'?'Ab diesem Monat alle 6 Monate.':'Für die Jahresprognose.'}</small></label>}</>}
{(type==='incomes'||type==='expenses'||type==='transfers')&&<><label><span>Kategorie</span><select value={categoryId} onChange={e=>setCategoryId(e.target.value)}><option value="">Ohne Kategorie</option>{data.categories.map(c=><option key={c.id} value={c.id}>{c.name}</option>)}<option value="__new__">+ Neue Kategorie </option></select></label>{categoryId==='__new__'&&<label className="new-category-field"><span>Neue Kategorie</span><input value={newCategoryName} onChange={e=>setNewCategoryName(e.target.value)} placeholder="z. B. Sparen"/></label>}</>} {(type==='incomes'||type==='expenses'||type==='transfers')&&<><label><span>Kategorie</span><select value={categoryId} onChange={e=>setCategoryId(e.target.value)}><option value="">Ohne Kategorie</option>{data.categories.map(c=><option key={c.id} value={c.id}>{c.name}</option>)}<option value="__new__">+ Neue Kategorie </option></select></label>{categoryId==='__new__'&&<label className="new-category-field"><span>Neue Kategorie</span><input value={newCategoryName} onChange={e=>setNewCategoryName(e.target.value)} placeholder="z. B. Sparen"/></label>}</>}
{(type==='incomes'||type==='expenses')&&<label><span>Konto</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label>} {(type==='incomes'||type==='expenses')&&<label><span>Konto</span><select value={accountId} onChange={e=>setAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label>}
{type==='expenses'&&<button type="button" className={`reminder-toggle ${pushoverReminder?'active':''}`} aria-pressed={pushoverReminder} onClick={()=>setPushoverReminder(x=>!x)}><span className={`checkbox-mark ${pushoverReminder?'checked':''}`}>{pushoverReminder?'✓':''}</span><span><strong>Pushover-Erinnerung</strong><small>Am 1. des Fälligkeitsmonats mit Betrag und Konto erinnern.</small></span></button>}
{type==='transfers'&&<><label><span>Von Konto</span><select value={fromAccountId} onChange={e=>setFromAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label><label><span>Nach Konto</span><select value={toAccountId} onChange={e=>setToAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label></>}<div className="modal-actions"><button type="button" className="secondary" onClick={onClose}>Abbrechen</button><button type="submit" className="primary">Speichern</button></div></form></div>; {type==='transfers'&&<><label><span>Von Konto</span><select value={fromAccountId} onChange={e=>setFromAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label><label><span>Nach Konto</span><select value={toAccountId} onChange={e=>setToAccountId(e.target.value)}>{data.accounts.map(a=><option key={a.id} value={a.id}>{a.name}</option>)}</select></label></>}<div className="modal-actions"><button type="button" className="secondary" onClick={onClose}>Abbrechen</button><button type="submit" className="primary">Speichern</button></div></form></div>;
} }
+35
View File
@@ -863,3 +863,38 @@ code { color:#b8c3ff; }
.year-flow-breakdown { grid-template-columns:1fr 1fr; gap:6px; } .year-flow-breakdown { grid-template-columns:1fr 1fr; gap:6px; }
.year-flow-breakdown > div { padding:8px; } .year-flow-breakdown > div { padding:8px; }
} }
/* v3.15: Pushover-Reminder */
.reminder-badge { display:inline-flex; align-items:center; margin-left:7px; padding:3px 6px; border:1px solid #675c2a; background:#2b2616; color:#e9d98b; border-radius:999px; font-size:8px; font-weight:850; text-transform:uppercase; letter-spacing:.045em; vertical-align:middle; white-space:nowrap; }
.movement-card-head .reminder-badge { margin-left:0; margin-top:5px; width:max-content; }
.reminder-toggle { width:100%; display:flex; align-items:center; gap:11px; text-align:left; border:1px solid #2d394b; background:#101722; color:var(--text); border-radius:12px; padding:11px 12px; }
.reminder-toggle.active { border-color:#665d2c; background:#211f16; }
.checkbox-mark { width:22px; height:22px; flex:0 0 auto; display:grid; place-items:center; border-radius:6px; border:1px solid #46536a; background:#0b111a; color:#101522; font-size:14px; font-weight:950; }
.checkbox-mark.checked { background:#e1ca6c; border-color:#e1ca6c; }
.reminder-toggle > span:last-child { min-width:0; }
.reminder-toggle strong { display:block; font-size:12px; }
.reminder-toggle small { display:block; margin-top:2px; color:var(--muted); font-size:10px; line-height:1.35; }
.pushover-settings .section-head { align-items:center; }
.pushover-status { flex:0 0 auto; border:1px solid #4a3540; background:#231820; color:#d69aa9; border-radius:999px; padding:5px 8px; font-size:9px; font-weight:850; text-transform:uppercase; letter-spacing:.05em; }
.pushover-status.configured { border-color:#275744; background:#11281f; color:#7bd6ac; }
.pushover-current { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:12px; padding:10px 12px; border:1px solid #29364a; background:#0d131c; border-radius:10px; }
.pushover-current span { color:var(--muted); font-size:9px; text-transform:uppercase; letter-spacing:.055em; font-weight:800; }
.pushover-current strong { font-size:11px; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }
.pushover-form { display:grid; grid-template-columns:1fr 1fr; gap:10px 12px; }
.pushover-form label { display:grid; gap:5px; }
.pushover-form label span { color:var(--muted); font-size:10px; font-weight:700; }
.pushover-form input { width:100%; background:#0b111a; color:#f5f7fb; border:1px solid #2a374a; border-radius:10px; padding:10px 11px; outline:none; }
.pushover-form input:focus { border-color:var(--accent); box-shadow:0 0 0 3px #8293ff1d; }
.pushover-actions { grid-column:1/-1; margin-top:2px; }
.pushover-actions .danger { border:1px solid #5e3038; background:#2d181d; color:#ff9aa2; }
.pushover-help { margin:10px 0 0; color:var(--muted); font-size:9px; line-height:1.45; }
.compact-alert { margin-bottom:10px; }
.success-note { margin-bottom:10px; padding:9px 11px; border:1px solid #275744; background:#11281f; color:#8de0ba; border-radius:10px; font-size:10px; }
@media (max-width:760px) {
.pushover-settings .section-head { align-items:flex-start; }
.pushover-form { grid-template-columns:1fr; }
.pushover-actions { grid-column:auto; display:grid; grid-template-columns:1fr; }
.pushover-actions button { width:100%; min-height:42px; }
.reminder-toggle { padding:10px; }
}