diff --git a/apps/tasks/app/dashboard/page.tsx b/apps/tasks/app/dashboard/page.tsx new file mode 100644 index 0000000..88602cd --- /dev/null +++ b/apps/tasks/app/dashboard/page.tsx @@ -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 = { + 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([]); + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(true); + const [enabledWidgets, setEnabledWidgets] = useState(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 ( +
+
+ Aktualni ukoly + Zobrazit vse → +
+ {loading ? ( +
Nacitam...
+ ) : activeTasks.length === 0 ? ( +
Zadne aktivni ukoly
+ ) : ( +
+ {activeTasks.slice(0, 5).map(task => ( +
+
+ {task.title} + {task.group_icon && {task.group_icon}} +
+ ))} +
+ )} +
+ ); + + case "category_time": + if (!activeGroup) return null; + return ( +
+
Aktivni kategorie
+
+
+ {activeGroup.icon || "\uD83D\uDCC1"} +
+
+
{activeGroup.display_name || activeGroup.name}
+ {activeGroup.time_zones?.[0] && ( +
+ {activeGroup.time_zones[0].from} \u2013 {activeGroup.time_zones[0].to} +
+ )} +
+
+
+ ); + + case "today_progress": + return ( +
+
Dnesni pokrok
+
+ {progress.done} + / {progress.total} ukolu +
+ {progress.total > 0 && ( +
+
+
+ )} + {progress.total === 0 && ( +
Zadne ukoly na dnes
+ )} +
+ ); + + case "next_task": + return ( +
+
Pristi ukol
+ {nextTask ? ( +
+
{nextTask.title}
+ {nextTask.due_at && ( +
+ {new Date(nextTask.due_at).toLocaleString("cs-CZ", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" })} +
+ )} +
+ ) : ( +
Zadne nadchazejici ukoly
+ )} +
+ ); + + case "motivace": + return ( +
+
Motivace
+
{motivace}
+
+ ); + + case "calendar_mini": { + const DAYS = ["Po", "Ut", "St", "Ct", "Pa", "So", "Ne"]; + return ( +
+
Tento tyden
+
+ {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 ( +
+ {DAYS[i]} +
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()} +
+ {taskCount > 0 &&
} +
+ ); + })} +
+
+ ); + } + } + } + + return ( +
+ {/* Header with time */} +
+
+
{timeStr}
+
+ {now.toLocaleDateString("cs-CZ", { weekday: "long", day: "numeric", month: "long" })} +
+
+
+ + + + + + + + + + + +
+
+ + {/* Widgets */} +
+ {enabledWidgets.map(wType => renderWidget(wType))} +
+
+ ); +} diff --git a/apps/tasks/app/layout.tsx b/apps/tasks/app/layout.tsx index 1bb1d97..824011f 100644 --- a/apps/tasks/app/layout.tsx +++ b/apps/tasks/app/layout.tsx @@ -4,6 +4,7 @@ import ThemeProvider from "@/components/ThemeProvider"; import AuthProvider from "@/components/AuthProvider"; import Header from "@/components/Header"; import BottomNav from "@/components/BottomNav"; +import InactivityMonitor from "@/components/InactivityMonitor"; import { I18nProvider } from "@/lib/i18n"; export const metadata: Metadata = { @@ -44,6 +45,7 @@ export default function RootLayout({ {children} + diff --git a/apps/tasks/app/lockscreen/page.tsx b/apps/tasks/app/lockscreen/page.tsx new file mode 100644 index 0000000..f73b39c --- /dev/null +++ b/apps/tasks/app/lockscreen/page.tsx @@ -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([]); + const [groups, setGroups] = useState([]); + const [swipeStart, setSwipeStart] = useState(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 ( +
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 */} +
+ + {/* Clock section */} +
+
+ + {timeStr} + + + {seconds} + +
+
+ {dateStr} +
+ + {/* Active group badge */} + {activeGroup && ( +
+ {activeGroup.icon || "\uD83D\uDCC1"} +
+
+ {activeGroup.display_name || activeGroup.name} +
+ {activeGroup.time_zones?.[0] && ( +
+ {activeGroup.time_zones[0].from} \u2013 {activeGroup.time_zones[0].to} +
+ )} +
+
+ )} +
+ + {/* Tasks panel */} + {activeTasks.length > 0 && ( +
+
+
+ \u00DAkoly ({activeTasks.length}) +
+
+ {activeTasks.map(task => ( +
+
+ + {task.title} + + {task.group_icon && {task.group_icon}} +
+ ))} +
+
+
+ )} + + {/* Unlock indicator */} +
+
+
+ Klepn\u011Bte nebo p\u0159eje\u010Fte nahoru +
+
+
+ ); +} diff --git a/apps/tasks/app/page.tsx b/apps/tasks/app/page.tsx index 5d47943..cd051c4 100644 --- a/apps/tasks/app/page.tsx +++ b/apps/tasks/app/page.tsx @@ -10,7 +10,7 @@ export default function Home() { useEffect(() => { if (token) { - router.replace("/tasks"); + router.replace("/dashboard"); } else { router.replace("/login"); } diff --git a/apps/tasks/app/settings/page.tsx b/apps/tasks/app/settings/page.tsx index 6eef562..2a7b170 100644 --- a/apps/tasks/app/settings/page.tsx +++ b/apps/tasks/app/settings/page.tsx @@ -7,6 +7,20 @@ import { useTheme } from "@/components/ThemeProvider"; import { useTranslation, LOCALES } from "@/lib/i18n"; import type { Locale } from "@/lib/i18n"; import type { Group } from "@/lib/api"; +import Link from "next/link"; + +type WidgetType = "current_tasks" | "category_time" | "today_progress" | "next_task" | "motivace" | "calendar_mini"; + +const ALL_WIDGETS: { key: WidgetType; label: string }[] = [ + { key: "current_tasks", label: "Aktualni ukoly" }, + { key: "category_time", label: "Aktivni kategorie" }, + { key: "today_progress", label: "Dnesni pokrok" }, + { key: "next_task", label: "Pristi ukol" }, + { key: "motivace", label: "Motivace" }, + { key: "calendar_mini", label: "Mini kalendar" }, +]; + +const DEFAULT_WIDGETS: WidgetType[] = ["current_tasks", "category_time", "today_progress"]; interface GroupSetting { from: string; @@ -33,6 +47,12 @@ export default function SettingsPage() { const [groupSettings, setGroupSettings] = useState>({}); const [expandedGroup, setExpandedGroup] = useState(null); const [savedGroup, setSavedGroup] = useState(null); + const [widgetEnabled, setWidgetEnabled] = useState>(() => { + const defaults: Record = { current_tasks: true, category_time: true, today_progress: true, next_task: false, motivace: false, calendar_mini: false }; + return defaults; + }); + const [inactivityTimeout, setInactivityTimeout] = useState(5); + const [widgetSaved, setWidgetSaved] = useState(false); useEffect(() => { if (!token) { @@ -48,6 +68,20 @@ export default function SettingsPage() { // ignore } } + const savedWidgets = localStorage.getItem("widget_config"); + if (savedWidgets) { + try { + const cfg = JSON.parse(savedWidgets); + if (Array.isArray(cfg.enabled)) { + const map: Record = { current_tasks: false, category_time: false, today_progress: false, next_task: false, motivace: false, calendar_mini: false }; + for (const w of cfg.enabled) map[w as WidgetType] = true; + setWidgetEnabled(map); + } + if (cfg.inactivityTimeout > 0) setInactivityTimeout(cfg.inactivityTimeout); + } catch { + // ignore + } + } } }, [token, router]); @@ -129,6 +163,18 @@ export default function SettingsPage() { setTimeout(() => setSavedGroup(null), 2000); } + function toggleWidget(key: WidgetType) { + setWidgetEnabled(prev => ({ ...prev, [key]: !prev[key] })); + } + + function saveWidgetConfig() { + const enabled = ALL_WIDGETS.filter(w => widgetEnabled[w.key]).map(w => w.key); + const config = { enabled, inactivityTimeout }; + localStorage.setItem("widget_config", JSON.stringify(config)); + setWidgetSaved(true); + setTimeout(() => setWidgetSaved(false), 2000); + } + function handleSave() { if (typeof window !== "undefined") { localStorage.setItem("taskteam_notifications", JSON.stringify(notifications)); @@ -285,6 +331,65 @@ export default function SettingsPage() {
+ {/* Widget & Lock Screen settings */} +
+

Widgety & Zamykaci obrazovka

+
+ {ALL_WIDGETS.map(w => ( +
+ {w.label} + +
+ ))} +
+ + {/* Inactivity timeout */} +
+
+ Neaktivita (min) + {inactivityTimeout} min +
+ setInactivityTimeout(Number(e.target.value))} + className="w-full" + /> +

Po teto dobe neaktivity se zobrazi zamykaci obrazovka (pouze v PWA rezimu).

+
+ + {/* Preview lockscreen button */} + + Nahled zamykaci obrazovky + + + {/* Save widget config */} + +
+ {/* Groups settings */} {groups.length > 0 && (
diff --git a/apps/tasks/components/InactivityMonitor.tsx b/apps/tasks/components/InactivityMonitor.tsx new file mode 100644 index 0000000..35ad655 --- /dev/null +++ b/apps/tasks/components/InactivityMonitor.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useRouter, usePathname } from "next/navigation"; + +export default function InactivityMonitor() { + const router = useRouter(); + const pathname = usePathname(); + const timerRef = useRef | null>(null); + + useEffect(() => { + if (typeof window === "undefined") return; + + // Only activate in PWA standalone mode + const isStandalone = + window.matchMedia("(display-mode: standalone)").matches || + window.matchMedia("(display-mode: fullscreen)").matches || + (window.navigator as unknown as { standalone?: boolean }).standalone === true; + if (!isStandalone) return; + + // Skip on lockscreen/login pages + if (pathname?.startsWith("/lockscreen") || pathname?.startsWith("/login") || pathname?.startsWith("/register")) return; + + // Read timeout from localStorage + let timeoutMinutes = 5; + try { + const stored = localStorage.getItem("widget_config"); + if (stored) { + const cfg = JSON.parse(stored); + if (cfg.inactivityTimeout > 0) timeoutMinutes = cfg.inactivityTimeout; + } + } catch { /* ignore */ } + + const timeoutMs = timeoutMinutes * 60 * 1000; + + function resetTimer() { + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + router.push("/lockscreen"); + }, timeoutMs); + } + + const events = ["mousedown", "mousemove", "keydown", "touchstart", "scroll", "click"]; + events.forEach(e => window.addEventListener(e, resetTimer, { passive: true })); + resetTimer(); + + return () => { + if (timerRef.current) clearTimeout(timerRef.current); + events.forEach(e => window.removeEventListener(e, resetTimer)); + }; + }, [pathname, router]); + + return null; +} diff --git a/apps/tasks/public/manifest.json b/apps/tasks/public/manifest.json index 14f101b..4dc04a6 100644 --- a/apps/tasks/public/manifest.json +++ b/apps/tasks/public/manifest.json @@ -3,6 +3,8 @@ "short_name": "Tasks", "start_url": "/", "display": "standalone", + "display_override": ["fullscreen", "standalone"], + "orientation": "portrait", "background_color": "#0A0A0F", "theme_color": "#1D4ED8", "icons": [