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:
283
apps/tasks/app/dashboard/page.tsx
Normal file
283
apps/tasks/app/dashboard/page.tsx
Normal 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 →</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user