PWA widgets dashboard, lock screen, screensaver with active category

- Dashboard (/dashboard): configurable widget system with 6 widget types
  (current_tasks, category_time, today_progress, next_task, motivace, calendar_mini)
  stored in localStorage widget_config
- Lock screen (/lockscreen): fullscreen with clock, active group badge,
  up to 4 current tasks, gradient from group color, swipe/tap to unlock
- InactivityMonitor: auto-redirect to lockscreen after configurable timeout
  (only in PWA standalone/fullscreen mode)
- Settings: widget toggle switches, inactivity timeout slider, lockscreen preview
- manifest.json: added display_override ["fullscreen","standalone"] + orientation portrait

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-30 11:32:08 +00:00
parent f2915b79fa
commit 4ace4d5f7d
7 changed files with 686 additions and 1 deletions

View File

@@ -0,0 +1,239 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth";
import { getTasks, getGroups, Task, Group } from "@/lib/api";
function getActiveGroup(groups: Group[]): Group | null {
const now = new Date();
const currentDay = now.getDay();
const pad = (n: number) => String(n).padStart(2, "0");
const currentTime = `${pad(now.getHours())}:${pad(now.getMinutes())}`;
for (const group of groups) {
for (const tz of group.time_zones || []) {
if (tz.days?.length && !tz.days.includes(currentDay)) continue;
if (tz.from && tz.to && tz.from <= currentTime && currentTime <= tz.to) return group;
}
}
return groups[0] || null;
}
function hexToRgb(hex: string): string {
const r = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!r) return "29, 78, 216";
return `${parseInt(r[1], 16)}, ${parseInt(r[2], 16)}, ${parseInt(r[3], 16)}`;
}
export default function LockScreen() {
const { token } = useAuth();
const router = useRouter();
const [now, setNow] = useState(new Date());
const [tasks, setTasks] = useState<Task[]>([]);
const [groups, setGroups] = useState<Group[]>([]);
const [swipeStart, setSwipeStart] = useState<number | null>(null);
const [swipeOffset, setSwipeOffset] = useState(0);
useEffect(() => {
const t = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(t);
}, []);
const loadData = useCallback(async () => {
if (!token) return;
try {
const [tasksRes, groupsRes] = await Promise.all([getTasks(token), getGroups(token)]);
setTasks(tasksRes.data || []);
setGroups(groupsRes.data || []);
} catch { /* ignore */ }
}, [token]);
useEffect(() => { loadData(); }, [loadData]);
// Reload data every 5 minutes
useEffect(() => {
const i = setInterval(loadData, 5 * 60 * 1000);
return () => clearInterval(i);
}, [loadData]);
function handleUnlock() {
router.push("/tasks");
}
function handleTouchStart(e: React.TouchEvent) {
setSwipeStart(e.touches[0].clientY);
setSwipeOffset(0);
}
function handleTouchMove(e: React.TouchEvent) {
if (swipeStart === null) return;
const delta = swipeStart - e.touches[0].clientY;
if (delta > 0) setSwipeOffset(Math.min(delta, 150));
}
function handleTouchEnd() {
if (swipeOffset > 80) handleUnlock();
setSwipeStart(null);
setSwipeOffset(0);
}
const activeGroup = getActiveGroup(groups);
const activeTasks = tasks
.filter(t => t.status === "pending" || t.status === "in_progress")
.slice(0, 4);
const color = activeGroup?.color || "#1D4ED8";
const rgb = hexToRgb(color);
const pad = (n: number) => String(n).padStart(2, "0");
const timeStr = `${pad(now.getHours())}:${pad(now.getMinutes())}`;
const seconds = pad(now.getSeconds());
const dateStr = now.toLocaleDateString("cs-CZ", { weekday: "long", day: "numeric", month: "long" });
return (
<div
style={{
position: "fixed",
inset: 0,
zIndex: 9999,
background: `linear-gradient(160deg, rgba(${rgb}, 0.35) 0%, rgba(${rgb}, 0.08) 30%, #0A0A0F 60%)`,
display: "flex",
flexDirection: "column",
userSelect: "none",
cursor: "pointer",
transform: swipeOffset > 0 ? `translateY(-${swipeOffset * 0.3}px)` : undefined,
transition: swipeOffset === 0 ? "transform 0.3s ease" : "none",
overflow: "hidden",
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onClick={handleUnlock}
>
{/* Ambient glow */}
<div style={{
position: "absolute",
top: -100,
left: "50%",
transform: "translateX(-50%)",
width: 400,
height: 400,
borderRadius: "50%",
background: `radial-gradient(circle, rgba(${rgb}, 0.15) 0%, transparent 70%)`,
pointerEvents: "none",
}} />
{/* Clock section */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", paddingTop: 40 }}>
<div style={{ display: "flex", alignItems: "baseline", gap: 4 }}>
<span style={{ fontSize: 88, fontWeight: 200, color: "#FFFFFF", letterSpacing: -4, lineHeight: 1, fontFamily: "system-ui" }}>
{timeStr}
</span>
<span style={{ fontSize: 28, fontWeight: 200, color: "rgba(255,255,255,0.4)" }}>
{seconds}
</span>
</div>
<div style={{ fontSize: 17, color: "rgba(255,255,255,0.5)", marginTop: 8, textTransform: "capitalize" }}>
{dateStr}
</div>
{/* Active group badge */}
{activeGroup && (
<div style={{
marginTop: 36,
display: "flex",
alignItems: "center",
gap: 14,
background: "rgba(255,255,255,0.06)",
borderRadius: 20,
padding: "14px 24px",
backdropFilter: "blur(20px)",
border: "1px solid rgba(255,255,255,0.08)",
}}>
<span style={{ fontSize: 32 }}>{activeGroup.icon || "\uD83D\uDCC1"}</span>
<div>
<div style={{ fontSize: 19, fontWeight: 600, color: "#FFFFFF", letterSpacing: 0.3 }}>
{activeGroup.display_name || activeGroup.name}
</div>
{activeGroup.time_zones?.[0] && (
<div style={{ fontSize: 13, color, marginTop: 3, opacity: 0.9 }}>
{activeGroup.time_zones[0].from} \u2013 {activeGroup.time_zones[0].to}
</div>
)}
</div>
</div>
)}
</div>
{/* Tasks panel */}
{activeTasks.length > 0 && (
<div style={{ padding: "0 20px 24px" }}>
<div style={{
background: "rgba(255,255,255,0.05)",
borderRadius: 24,
padding: "16px 20px",
backdropFilter: "blur(20px)",
border: "1px solid rgba(255,255,255,0.06)",
}}>
<div style={{
fontSize: 11,
color: "rgba(255,255,255,0.35)",
textTransform: "uppercase",
letterSpacing: 1.5,
fontWeight: 600,
marginBottom: 12,
}}>
\u00DAkoly ({activeTasks.length})
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{activeTasks.map(task => (
<div key={task.id} style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div style={{
width: 7,
height: 7,
borderRadius: "50%",
background: task.status === "in_progress" ? "#60A5FA" : "#FBBF24",
flexShrink: 0,
boxShadow: `0 0 6px ${task.status === "in_progress" ? "#60A5FA" : "#FBBF24"}80`,
}} />
<span style={{
fontSize: 15,
color: "rgba(255,255,255,0.8)",
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}>
{task.title}
</span>
{task.group_icon && <span style={{ fontSize: 14 }}>{task.group_icon}</span>}
</div>
))}
</div>
</div>
</div>
)}
{/* Unlock indicator */}
<div style={{
paddingBottom: 44,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 10,
}}>
<div style={{
width: 36,
height: 5,
borderRadius: 3,
background: "rgba(255,255,255,0.25)",
}} />
<div style={{
fontSize: 13,
color: "rgba(255,255,255,0.3)",
letterSpacing: 0.5,
}}>
Klepn\u011Bte nebo p\u0159eje\u010Fte nahoru
</div>
</div>
</div>
);
}