fix: 4 bugs — MSK today events, settings scroll, note dates, persistent notes volume
All checks were successful
Deploy / deploy (push) Successful in 4m35s
All checks were successful
Deploy / deploy (push) Successful in 4m35s
- calendar API: today/week ranges use Moscow time (UTC+3) instead of UTC — previously today events did not appear until 03:00 MSK - settings tab: add -webkit-overflow-scrolling: touch + touchAction pan-y for tablet scroll - NotesTab: add date picker (pinDate) in editor header + date badge in list - home: pinnedNotes now filters by pinDate (today or future), falls back to latest - notes/auth: storage moved from /tmp to /data (falls back to /tmp if /data missing) - deploy workflow: mount /opt/digital-home/smart-home-tablet-data:/data so notes survive redeploys
This commit is contained in:
@@ -29,5 +29,6 @@ jobs:
|
|||||||
--label traefik.http.routers.tablet.tls.certresolver=letsencrypt \
|
--label traefik.http.routers.tablet.tls.certresolver=letsencrypt \
|
||||||
--label traefik.http.services.tablet.loadbalancer.server.port=3000 \
|
--label traefik.http.services.tablet.loadbalancer.server.port=3000 \
|
||||||
--env-file /opt/digital-home/tablet.env \
|
--env-file /opt/digital-home/tablet.env \
|
||||||
|
-v /opt/digital-home/smart-home-tablet-data:/data \
|
||||||
smart-home-tablet:latest
|
smart-home-tablet:latest
|
||||||
echo 'Deploy done'
|
echo 'Deploy done'
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import * as fs from 'fs'
|
|||||||
import * as path from 'path'
|
import * as path from 'path'
|
||||||
|
|
||||||
const SECRET = process.env.APP_SECRET || 'smart-home-default-secret-change-me'
|
const SECRET = process.env.APP_SECRET || 'smart-home-default-secret-change-me'
|
||||||
const CONFIG_PATH = '/tmp/tablet-config.json'
|
const DATA_DIR = fs.existsSync('/data') ? '/data' : '/tmp'
|
||||||
|
const CONFIG_PATH = `${DATA_DIR}/tablet-config.json`
|
||||||
|
|
||||||
function loadConfig(): { pin: string } {
|
function loadConfig(): { pin: string } {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -43,12 +43,21 @@ export async function GET(req: Request) {
|
|||||||
let timeMin: string
|
let timeMin: string
|
||||||
let timeMax: string
|
let timeMax: string
|
||||||
|
|
||||||
|
// Moscow boundaries (UTC+3, no DST). 00:00 MSK = 21:00 UTC previous day.
|
||||||
|
const MSK_OFFSET_MS = 3 * 3600 * 1000
|
||||||
|
const mskNow = new Date(now.getTime() + MSK_OFFSET_MS)
|
||||||
|
const my = mskNow.getUTCFullYear()
|
||||||
|
const mm = mskNow.getUTCMonth()
|
||||||
|
const md = mskNow.getUTCDate()
|
||||||
|
const mskMidnight = (y: number, m: number, d: number) =>
|
||||||
|
new Date(Date.UTC(y, m, d) - MSK_OFFSET_MS).toISOString()
|
||||||
|
|
||||||
if (range === 'today') {
|
if (range === 'today') {
|
||||||
timeMin = new Date(now.getFullYear(), now.getMonth(), now.getDate()).toISOString()
|
timeMin = mskMidnight(my, mm, md)
|
||||||
timeMax = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).toISOString()
|
timeMax = mskMidnight(my, mm, md + 1)
|
||||||
} else if (range === 'week') {
|
} else if (range === 'week') {
|
||||||
timeMin = new Date(now.getFullYear(), now.getMonth(), now.getDate()).toISOString()
|
timeMin = mskMidnight(my, mm, md)
|
||||||
timeMax = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 7).toISOString()
|
timeMax = mskMidnight(my, mm, md + 7)
|
||||||
} else {
|
} else {
|
||||||
// month — support year/month query params
|
// month — support year/month query params
|
||||||
const targetYear = parseInt(searchParams.get('year') || String(now.getFullYear()))
|
const targetYear = parseInt(searchParams.get('year') || String(now.getFullYear()))
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ export const dynamic = 'force-dynamic'
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import * as fs from 'fs'
|
import * as fs from 'fs'
|
||||||
|
|
||||||
const NOTES_PATH = '/tmp/tablet-notes.json'
|
const DATA_DIR = fs.existsSync('/data') ? '/data' : '/tmp'
|
||||||
|
const NOTES_PATH = `${DATA_DIR}/tablet-notes.json`
|
||||||
|
|
||||||
interface Note {
|
interface Note {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
15
app/page.tsx
15
app/page.tsx
@@ -394,10 +394,19 @@ function HomeTab({ weather, sensors }: { weather: WeatherData | null; sensors: S
|
|||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
|
|
||||||
// Notes
|
// Notes — pinned (pinDate today or future), fallback to latest
|
||||||
fetch('/api/notes')
|
fetch('/api/notes')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(d => setPinnedNotes((d.notes || []).slice(0, 3)))
|
.then(d => {
|
||||||
|
const all = d.notes || []
|
||||||
|
const today = new Date(); today.setHours(0, 0, 0, 0)
|
||||||
|
const pinned = all.filter((n: any) => {
|
||||||
|
if (!n.pinDate) return false
|
||||||
|
const pd = new Date(n.pinDate); pd.setHours(0, 0, 0, 0)
|
||||||
|
return pd.getTime() >= today.getTime()
|
||||||
|
})
|
||||||
|
setPinnedNotes((pinned.length ? pinned : all).slice(0, 3))
|
||||||
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -650,7 +659,7 @@ function SettingsTab({ city, onCityChange, onLogout, theme, onThemeChange }: { c
|
|||||||
const currentCity = CITIES.find(c => c.id === city) || CITIES[0]
|
const currentCity = CITIES.find(c => c.id === city) || CITIES[0]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ flex: 1, overflowY: 'auto', padding: '24px', display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 560, margin: '0 auto', width: '100%' }}>
|
<div style={{ flex: 1, overflowY: 'auto', WebkitOverflowScrolling: 'touch' as any, touchAction: 'pan-y', padding: '24px', display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 560, margin: '0 auto', width: '100%' }}>
|
||||||
<h2 style={{ fontSize: 24, fontWeight: 800, color: 'var(--text-primary)', margin: '0 0 8px', letterSpacing: '-0.5px' }}>Настройки</h2>
|
<h2 style={{ fontSize: 24, fontWeight: 800, color: 'var(--text-primary)', margin: '0 0 8px', letterSpacing: '-0.5px' }}>Настройки</h2>
|
||||||
|
|
||||||
{/* City selector */}
|
{/* City selector */}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { Plus, X, Trash2, ShoppingCart, FileText, Check, Circle } from 'lucide-react'
|
import { Plus, X, Trash2, ShoppingCart, FileText, Check, Circle, Calendar as CalendarIcon } from 'lucide-react'
|
||||||
|
|
||||||
interface NoteItem {
|
interface NoteItem {
|
||||||
id: string
|
id: string
|
||||||
@@ -15,6 +15,7 @@ interface Note {
|
|||||||
items?: NoteItem[]
|
items?: NoteItem[]
|
||||||
text?: string
|
text?: string
|
||||||
color: string
|
color: string
|
||||||
|
pinDate: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
@@ -157,6 +158,12 @@ export default function NotesTab() {
|
|||||||
{note.title}
|
{note.title}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{note.pinDate && (
|
||||||
|
<div style={{ fontSize: 11, color: note.color, fontWeight: 600, marginBottom: 4, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<CalendarIcon size={11} />
|
||||||
|
{new Date(note.pinDate).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{note.type === 'shopping' && totalCount > 0 && (
|
{note.type === 'shopping' && totalCount > 0 && (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -208,9 +215,10 @@ export default function NotesTab() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '18px 24px', borderBottom: '1px solid rgba(255,255,255,0.05)',
|
padding: '18px 24px', borderBottom: '1px solid rgba(255,255,255,0.05)',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
display: 'flex', flexDirection: 'column', gap: 10,
|
||||||
background: `${activeNote.color}08`,
|
background: `${activeNote.color}08`,
|
||||||
}}>
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||||
<input
|
<input
|
||||||
value={activeNote.title}
|
value={activeNote.title}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
@@ -228,11 +236,47 @@ export default function NotesTab() {
|
|||||||
width: 32, height: 32, borderRadius: 10,
|
width: 32, height: 32, borderRadius: 10,
|
||||||
background: 'rgba(239,68,68,0.08)',
|
background: 'rgba(239,68,68,0.08)',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
color: '#f87171',
|
color: '#f87171', flexShrink: 0,
|
||||||
}}>
|
}}>
|
||||||
<Trash2 size={15} />
|
<Trash2 size={15} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<CalendarIcon size={14} color={activeNote.color} />
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={activeNote.pinDate || ''}
|
||||||
|
onChange={e => {
|
||||||
|
const v = e.target.value || null
|
||||||
|
setActiveNote(prev => prev ? { ...prev, pinDate: v } : null)
|
||||||
|
updateNote(activeNote.id, { pinDate: v })
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(255,255,255,0.04)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.07)',
|
||||||
|
borderRadius: 10, padding: '6px 10px',
|
||||||
|
color: 'var(--text-primary)', fontSize: 13,
|
||||||
|
outline: 'none', fontFamily: 'inherit',
|
||||||
|
colorScheme: 'dark' as any,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{activeNote.pinDate && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setActiveNote(prev => prev ? { ...prev, pinDate: null } : null)
|
||||||
|
updateNote(activeNote.id, { pinDate: null })
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: 'transparent', border: 'none',
|
||||||
|
color: 'var(--text-tertiary)', fontSize: 12,
|
||||||
|
padding: '4px 8px', cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Очистить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 24px 24px' }}>
|
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 24px 24px' }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user