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,283 @@
"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";
import Link from "next/link";
type WidgetType = "current_tasks" | "category_time" | "today_progress" | "next_task" | "motivace" | "calendar_mini";
const DEFAULT_WIDGETS: WidgetType[] = ["current_tasks", "category_time", "today_progress"];
const WIDGET_LABELS: Record<WidgetType, string> = {
current_tasks: "Aktualni ukoly",
category_time: "Aktivni kategorie",
today_progress: "Dnesni pokrok",
next_task: "Pristi ukol",
motivace: "Motivace",
calendar_mini: "Mini kalendar",
};
const MOTIVACE_LIST = [
"Kazdy ukol, ktery dokoncis, te priblizuje k cili.",
"Male kroky vedou k velkym vysledkum.",
"Dnes je skvely den na splneni ukolu.",
"Soustred se na to, co muzes ovlivnit.",
"Tvoje prace ma smysl. Pokracuj!",
"Disciplina premaha talent, kdyz talent nema disciplinu.",
"Nejlepsi cas zacit byl vcera. Druhy nejlepsi je ted.",
];
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 getTodayProgress(tasks: Task[]) {
const today = new Date().toISOString().slice(0, 10);
const all = tasks.filter(t =>
t.due_at?.startsWith(today) || t.scheduled_at?.startsWith(today) ||
(t.status !== "cancelled" && t.created_at?.startsWith(today))
);
const done = all.filter(t => t.status === "done").length;
return { done, total: all.length };
}
function getNextTask(tasks: Task[]): Task | null {
const now = new Date().toISOString();
return tasks
.filter(t => t.status !== "done" && t.status !== "cancelled" && t.due_at && t.due_at > now)
.sort((a, b) => (a.due_at! > b.due_at! ? 1 : -1))[0] || null;
}
export default function DashboardPage() {
const { token } = useAuth();
const router = useRouter();
const [tasks, setTasks] = useState<Task[]>([]);
const [groups, setGroups] = useState<Group[]>([]);
const [loading, setLoading] = useState(true);
const [enabledWidgets, setEnabledWidgets] = useState<WidgetType[]>(DEFAULT_WIDGETS);
const [motivace] = useState(() => MOTIVACE_LIST[Math.floor(Math.random() * MOTIVACE_LIST.length)]);
const [now, setNow] = useState(new Date());
useEffect(() => {
if (!token) { router.replace("/login"); return; }
try {
const stored = localStorage.getItem("widget_config");
if (stored) {
const cfg = JSON.parse(stored);
if (Array.isArray(cfg.enabled) && cfg.enabled.length > 0) {
setEnabledWidgets(cfg.enabled);
}
}
} catch { /* ignore */ }
}, [token, router]);
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 */ }
setLoading(false);
}, [token]);
useEffect(() => { loadData(); }, [loadData]);
// Refresh every 2 minutes
useEffect(() => {
const i = setInterval(loadData, 2 * 60 * 1000);
return () => clearInterval(i);
}, [loadData]);
// Update clock every minute
useEffect(() => {
const t = setInterval(() => setNow(new Date()), 60000);
return () => clearInterval(t);
}, []);
if (!token) return null;
const activeGroup = getActiveGroup(groups);
const activeTasks = tasks.filter(t => t.status === "pending" || t.status === "in_progress");
const progress = getTodayProgress(tasks);
const nextTask = getNextTask(tasks);
const acColor = activeGroup?.color || "#4F46E5";
const pad = (n: number) => String(n).padStart(2, "0");
const timeStr = `${pad(now.getHours())}:${pad(now.getMinutes())}`;
function renderWidget(wType: WidgetType) {
switch (wType) {
case "current_tasks":
return (
<div key="current_tasks" className="bg-white dark:bg-[#13131A] rounded-2xl p-4 border border-gray-200 dark:border-gray-800" style={{ borderLeftColor: acColor, borderLeftWidth: 3 }}>
<div className="flex justify-between items-center mb-3">
<span className="text-xs font-semibold text-gray-500 dark:text-[#6B6B85] uppercase tracking-wider">Aktualni ukoly</span>
<Link href="/tasks" className="text-xs" style={{ color: acColor }}>Zobrazit vse &rarr;</Link>
</div>
{loading ? (
<div className="text-sm text-gray-400">Nacitam...</div>
) : activeTasks.length === 0 ? (
<div className="text-sm text-gray-400 text-center py-3">Zadne aktivni ukoly</div>
) : (
<div className="flex flex-col gap-2">
{activeTasks.slice(0, 5).map(task => (
<div key={task.id} className="flex items-center gap-2.5">
<div className="w-2 h-2 rounded-full shrink-0" style={{ background: task.status === "in_progress" ? "#60A5FA" : "#FBBF24" }} />
<span className="text-sm dark:text-[#F0F0F5] text-gray-800 truncate flex-1">{task.title}</span>
{task.group_icon && <span className="text-sm">{task.group_icon}</span>}
</div>
))}
</div>
)}
</div>
);
case "category_time":
if (!activeGroup) return null;
return (
<div key="category_time" className="rounded-2xl p-4 border" style={{ background: `linear-gradient(135deg, ${acColor}15, transparent)`, borderColor: `${acColor}30` }}>
<div className="text-xs font-semibold text-gray-500 dark:text-[#6B6B85] uppercase tracking-wider mb-3">Aktivni kategorie</div>
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-xl flex items-center justify-center text-2xl" style={{ background: `${acColor}20` }}>
{activeGroup.icon || "\uD83D\uDCC1"}
</div>
<div>
<div className="text-lg font-bold dark:text-[#F0F0F5] text-gray-900">{activeGroup.display_name || activeGroup.name}</div>
{activeGroup.time_zones?.[0] && (
<div className="text-sm mt-0.5" style={{ color: acColor }}>
{activeGroup.time_zones[0].from} \u2013 {activeGroup.time_zones[0].to}
</div>
)}
</div>
</div>
</div>
);
case "today_progress":
return (
<div key="today_progress" className="bg-white dark:bg-[#13131A] rounded-2xl p-4 border border-gray-200 dark:border-gray-800">
<div className="text-xs font-semibold text-gray-500 dark:text-[#6B6B85] uppercase tracking-wider mb-3">Dnesni pokrok</div>
<div className="flex items-baseline gap-2 mb-2.5">
<span className="text-3xl font-extrabold text-emerald-400">{progress.done}</span>
<span className="text-base text-gray-500">/ {progress.total} ukolu</span>
</div>
{progress.total > 0 && (
<div className="h-2 bg-gray-200 dark:bg-[#2A2A3A] rounded-full overflow-hidden">
<div
className="h-full bg-emerald-400 rounded-full transition-all duration-500"
style={{ width: `${Math.round((progress.done / progress.total) * 100)}%` }}
/>
</div>
)}
{progress.total === 0 && (
<div className="text-sm text-gray-400">Zadne ukoly na dnes</div>
)}
</div>
);
case "next_task":
return (
<div key="next_task" className="bg-white dark:bg-[#13131A] rounded-2xl p-4 border border-gray-200 dark:border-gray-800">
<div className="text-xs font-semibold text-gray-500 dark:text-[#6B6B85] uppercase tracking-wider mb-3">Pristi ukol</div>
{nextTask ? (
<div>
<div className="text-[15px] font-semibold dark:text-[#F0F0F5] text-gray-900 mb-1">{nextTask.title}</div>
{nextTask.due_at && (
<div className="text-sm text-amber-400">
{new Date(nextTask.due_at).toLocaleString("cs-CZ", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" })}
</div>
)}
</div>
) : (
<div className="text-sm text-gray-400">Zadne nadchazejici ukoly</div>
)}
</div>
);
case "motivace":
return (
<div key="motivace" className="rounded-2xl p-4 border" style={{ background: "linear-gradient(135deg, #4F46E510, transparent)", borderColor: "#4F46E530" }}>
<div className="text-xs font-semibold text-gray-500 dark:text-[#6B6B85] uppercase tracking-wider mb-2">Motivace</div>
<div className="text-[15px] text-gray-600 dark:text-[#C7C7D9] leading-relaxed">{motivace}</div>
</div>
);
case "calendar_mini": {
const DAYS = ["Po", "Ut", "St", "Ct", "Pa", "So", "Ne"];
return (
<div key="calendar_mini" className="bg-white dark:bg-[#13131A] rounded-2xl p-4 border border-gray-200 dark:border-gray-800">
<div className="text-xs font-semibold text-gray-500 dark:text-[#6B6B85] uppercase tracking-wider mb-3">Tento tyden</div>
<div className="flex gap-1">
{Array.from({ length: 7 }, (_, i) => {
const d = new Date(now);
const dow = (d.getDay() + 6) % 7; // Mon=0
d.setDate(d.getDate() + (i - dow));
const dateStr = d.toISOString().slice(0, 10);
const isToday = dateStr === now.toISOString().slice(0, 10);
const taskCount = tasks.filter(t => t.due_at?.startsWith(dateStr) || t.scheduled_at?.startsWith(dateStr)).length;
return (
<div key={i} className="flex-1 flex flex-col items-center gap-1">
<span className="text-[10px] text-gray-500 dark:text-[#6B6B85]">{DAYS[i]}</span>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-[13px] ${
isToday ? "bg-blue-600 text-white font-bold" : taskCount > 0 ? "bg-blue-600/10 dark:text-[#F0F0F5] text-gray-800" : "border border-gray-200 dark:border-[#2A2A3A] dark:text-[#F0F0F5] text-gray-800"
}`}
>
{d.getDate()}
</div>
{taskCount > 0 && <div className="w-1 h-1 rounded-full bg-blue-400" />}
</div>
);
})}
</div>
</div>
);
}
}
}
return (
<div className="max-w-lg mx-auto px-4 pb-24">
{/* Header with time */}
<div className="flex justify-between items-center py-4 pb-5">
<div>
<div className="text-3xl font-extrabold dark:text-[#F0F0F5] text-gray-900 leading-none">{timeStr}</div>
<div className="text-sm text-gray-500 dark:text-[#6B6B85] mt-1 capitalize">
{now.toLocaleDateString("cs-CZ", { weekday: "long", day: "numeric", month: "long" })}
</div>
</div>
<div className="flex gap-2">
<Link href="/lockscreen" className="w-10 h-10 rounded-xl bg-gray-100 dark:bg-[#13131A] flex items-center justify-center border border-gray-200 dark:border-gray-800" title="Zamykaci obrazovka">
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} className="text-gray-500 dark:text-[#6B6B85]">
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</svg>
</Link>
<Link href="/settings" className="w-10 h-10 rounded-xl bg-gray-100 dark:bg-[#13131A] flex items-center justify-center border border-gray-200 dark:border-gray-800">
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} className="text-gray-500 dark:text-[#6B6B85]">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</Link>
</div>
</div>
{/* Widgets */}
<div className="flex flex-col gap-3">
{enabledWidgets.map(wType => renderWidget(wType))}
</div>
</div>
);
}