From 49d010eb71ac4450d72ad0b28530416c7f53fdf6 Mon Sep 17 00:00:00 2001 From: Gabriel Menezes Date: Mon, 2 Nov 2026 10:24:37 -0400 Subject: [PATCH] feat(dashboard): add comprehensive period selector and time tracking to dashboard and dashboard v2 --- .../components/dashboard/BiUniformCard.tsx | 118 ++++--- .../dashboard/CreateBiCardModal.tsx | 11 +- .../components/dashboard/PeriodSelector.tsx | 315 ++++++++++++++++++ frontend/src/lib/periodUtils.ts | 237 +++++++++++++ frontend/src/lib/userPreferences.ts | 13 +- frontend/src/pages/DashboardPage.tsx | 150 +++++++-- frontend/src/pages/DashboardV2Page.tsx | 35 ++ 7 files changed, 809 insertions(+), 70 deletions(-) create mode 100644 frontend/src/components/dashboard/PeriodSelector.tsx create mode 100644 frontend/src/lib/periodUtils.ts diff --git a/frontend/src/components/dashboard/BiUniformCard.tsx b/frontend/src/components/dashboard/BiUniformCard.tsx index 8cb403d..5e0f6d2 100644 --- a/frontend/src/components/dashboard/BiUniformCard.tsx +++ b/frontend/src/components/dashboard/BiUniformCard.tsx @@ -22,17 +22,18 @@ import { import type { DashboardV2Card } from '../../lib/userPreferences' import type { Task, Project, Category } from '../../types/task' import { - calculateTaskTotalSeconds, formatSecondsToHuman, formatSecondsToTimer, } from '../../lib/timeUtils' import { calculateUserPeriodWorkedSeconds, - isSameDay, - isSameWeek, - isSameMonth, type WorkGoal, } from '../../lib/userGoals' +import { + type PeriodPreset, + getDateRangeForPreset, + calculateTaskSecondsInRange, +} from '../../lib/periodUtils' import { useAuth } from '../../contexts/AuthContext' interface BiUniformCardProps { @@ -46,6 +47,9 @@ interface BiUniformCardProps { activeSeconds: number workGoal: WorkGoal globalScope?: 'mine' | 'team' + globalPeriod?: PeriodPreset + globalCustomStart?: string + globalCustomEnd?: string isBeingDragged?: boolean isDragOver?: boolean onToggleTimer: (task: Task) => void @@ -124,6 +128,9 @@ export const BiUniformCard: React.FC = ({ activeSeconds, workGoal, globalScope = 'team', + globalPeriod = 'this_month', + globalCustomStart, + globalCustomEnd, isBeingDragged = false, isDragOver = false, onToggleTimer, @@ -138,7 +145,6 @@ export const BiUniformCard: React.FC = ({ }) => { const { user } = useAuth() const theme = THEME_STYLES[card.colorTheme || 'brand'] - const now = new Date() // Resolve scope: Card's specific scope takes precedence, otherwise fallback to page's globalScope const effectiveScope: 'mine' | 'team' = @@ -148,6 +154,28 @@ export const BiUniformCard: React.FC = ({ : 'team' : globalScope + // Resolve period: Card's specific period takes precedence, otherwise fallback to page's globalPeriod + const effectivePreset: PeriodPreset = (() => { + if (!card.period || card.period === 'INHERIT') { + return globalPeriod || 'this_month' + } + switch (card.period) { + case 'TODAY': return 'today' + case 'YESTERDAY': return 'yesterday' + case 'WEEK': return 'this_week' + case 'LAST_WEEK': return 'last_week' + case 'MONTH': return 'this_month' + case 'LAST_MONTH': return 'last_month' + case 'LAST_3_MONTHS': return 'last_3_months' + case 'LAST_6_MONTHS': return 'last_6_months' + case 'YEAR': return 'this_year' + case 'ALL': return 'all' + default: return 'all' + } + })() + + const currentRange = getDateRangeForPreset(effectivePreset, globalCustomStart, globalCustomEnd) + // 1. Filter Tasks based on scope and card's combined dimensions const filteredTasks = tasks.filter((t) => { // Scope filter (if 'mine', only user's tasks) @@ -168,46 +196,39 @@ export const BiUniformCard: React.FC = ({ return true }) - // Period-aware seconds calculation + // Period-aware seconds calculation using exact range intersection const periodSeconds = filteredTasks.reduce((acc, t) => { - let taskSec = 0 - if (card.period === 'ALL') { - taskSec = calculateTaskTotalSeconds(t) - } else { - for (const item of t.history || []) { - if (!item.startAt) continue - const itemDate = new Date(item.startAt) - let matches = false - if (card.period === 'TODAY') matches = isSameDay(itemDate, now) - else if (card.period === 'WEEK') matches = isSameWeek(itemDate, now) - else if (card.period === 'MONTH') matches = isSameMonth(itemDate, now) - - if (matches) { - const startMs = itemDate.getTime() - const endMs = item.endAt ? new Date(item.endAt).getTime() : startMs - const duration = Math.max(0, Math.floor((endMs - startMs) / 1000)) - taskSec += duration - } - } - } - - if (activeTask?.id === t.id && card.period !== 'ALL') { - taskSec += activeSeconds - } - - return acc + taskSec + return acc + calculateTaskSecondsInRange( + t, + currentRange.startDate, + currentRange.endDate, + activeTask?.id, + activeSeconds + ) }, 0) // Associated names const projectName = projects.find((p) => p.id === card.projectId)?.name const categoryName = categories.find((c) => c.id === card.categoryId)?.name - const periodLabels: Record = { - ALL: 'Total', - TODAY: 'Hoje', - WEEK: 'Esta Semana', - MONTH: 'Este Mês', - } + const periodBadgeLabel = (() => { + if (!card.period || card.period === 'INHERIT') { + return currentRange.label + } + switch (card.period) { + case 'TODAY': return 'Hoje' + case 'YESTERDAY': return 'Ontem' + case 'WEEK': return 'Esta Semana' + case 'LAST_WEEK': return 'Semana Passada' + case 'MONTH': return 'Este Mês' + case 'LAST_MONTH': return 'Mês Passado' + case 'LAST_3_MONTHS': return 'Últimos 3 Meses' + case 'LAST_6_MONTHS': return 'Últimos 6 Meses' + case 'YEAR': return 'Este Ano' + case 'ALL': return 'Total' + default: return 'Total' + } + })() // Render Body based on metricType const renderCardBody = () => { @@ -420,21 +441,32 @@ export const BiUniformCard: React.FC = ({ case 'TOP_TASKS': { const top2 = [...filteredTasks] - .sort((a, b) => calculateTaskTotalSeconds(b) - calculateTaskTotalSeconds(a)) + .map((t) => ({ + task: t, + sec: calculateTaskSecondsInRange( + t, + currentRange.startDate, + currentRange.endDate, + activeTask?.id, + activeSeconds + ), + })) + .filter((item) => item.sec > 0) + .sort((a, b) => b.sec - a.sec) .slice(0, 2) return (
{top2.length === 0 ? ( -

Nenhuma tarefa encontrada.

+

Nenhuma atividade no período.

) : ( - top2.map((t, i) => ( + top2.map(({ task: t, sec }, i) => (
#{i + 1} {t.title} - {formatSecondsToTimer(calculateTaskTotalSeconds(t))} + {formatSecondsToTimer(sec)}
)) @@ -576,7 +608,7 @@ export const BiUniformCard: React.FC = ({ - {periodLabels[card.period]} + {periodBadgeLabel} {projectName && ( diff --git a/frontend/src/components/dashboard/CreateBiCardModal.tsx b/frontend/src/components/dashboard/CreateBiCardModal.tsx index 590f187..a519b57 100644 --- a/frontend/src/components/dashboard/CreateBiCardModal.tsx +++ b/frontend/src/components/dashboard/CreateBiCardModal.tsx @@ -258,10 +258,17 @@ export const CreateBiCardModal: React.FC = ({ value={period} onChange={(val) => setPeriod(val as BiPeriodFilter)} options={[ - { value: 'ALL', label: 'Todo o Histórico' }, - { value: 'TODAY', label: 'Apenas Hoje' }, + { value: 'INHERIT', label: '🔗 Seguir Período da Página (Recomendado)' }, + { value: 'TODAY', label: '📅 Apenas Hoje' }, + { value: 'YESTERDAY', label: 'Ontem' }, { value: 'WEEK', label: 'Esta Semana' }, + { value: 'LAST_WEEK', label: 'Semana Passada' }, { value: 'MONTH', label: 'Este Mês' }, + { value: 'LAST_MONTH', label: 'Mês Passado' }, + { value: 'LAST_3_MONTHS', label: 'Últimos 3 Meses (Trimestre)' }, + { value: 'LAST_6_MONTHS', label: 'Últimos 6 Meses (Semestre)' }, + { value: 'YEAR', label: 'Este Ano' }, + { value: 'ALL', label: 'Todo o Histórico' }, ]} />
diff --git a/frontend/src/components/dashboard/PeriodSelector.tsx b/frontend/src/components/dashboard/PeriodSelector.tsx new file mode 100644 index 0000000..e854a59 --- /dev/null +++ b/frontend/src/components/dashboard/PeriodSelector.tsx @@ -0,0 +1,315 @@ +import React, { useState, useRef, useEffect } from 'react' +import { + Calendar, + ChevronDown, + Clock, + Check, + CalendarRange, +} from 'lucide-react' +import { + type PeriodPreset, + type DateRange, + PERIOD_PRESET_LABELS, + getDateRangeForPreset, + formatDatePtBr, + formatDateYmd, +} from '../../lib/periodUtils' + +interface PeriodSelectorProps { + value: PeriodPreset + customStart?: string + customEnd?: string + onChange: (preset: PeriodPreset, customStart?: string, customEnd?: string) => void + className?: string +} + +const PRIMARY_PRESETS: PeriodPreset[] = ['today', 'this_week', 'this_month', 'last_3_months', 'this_year'] + +const SECONDARY_PRESETS: PeriodPreset[] = [ + 'yesterday', + 'last_week', + 'last_month', + 'last_6_months', + 'all', +] + +export const PeriodSelector: React.FC = ({ + value, + customStart, + customEnd, + onChange, + className = '', +}) => { + const [dropdownOpen, setDropdownOpen] = useState(false) + const [customModalOpen, setCustomModalOpen] = useState(false) + + // Local state for custom dates + const defaultDates = () => { + const now = new Date() + const firstDay = new Date(now.getFullYear(), now.getMonth(), 1) + const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0) + return { + start: customStart || formatDateYmd(firstDay), + end: customEnd || formatDateYmd(lastDay), + } + } + + const [tempStart, setTempStart] = useState(defaultDates().start) + const [tempEnd, setTempEnd] = useState(defaultDates().end) + + const dropdownRef = useRef(null) + + // Close dropdown on outside click + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setDropdownOpen(false) + } + } + if (dropdownOpen) { + document.addEventListener('mousedown', handleClickOutside) + } + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [dropdownOpen]) + + const currentRange: DateRange = getDateRangeForPreset(value, customStart, customEnd) + + const handleSelectPreset = (preset: PeriodPreset) => { + if (preset === 'custom') { + setCustomModalOpen(true) + setDropdownOpen(false) + return + } + setDropdownOpen(false) + onChange(preset) + } + + const handleApplyCustom = (e: React.FormEvent) => { + e.preventDefault() + if (!tempStart || !tempEnd) return + onChange('custom', tempStart, tempEnd) + setCustomModalOpen(false) + } + + const isSecondaryActive = SECONDARY_PRESETS.includes(value) + + return ( +
+ {/* Left: Buttons & Presets */} +
+
+ + Período: +
+ +
+ {PRIMARY_PRESETS.map((p) => { + const active = value === p + return ( + + ) + })} + + {/* More Presets Dropdown */} +
+ + + {dropdownOpen && ( +
+ {SECONDARY_PRESETS.map((p) => { + const active = value === p + return ( + + ) + })} +
+ )} +
+ + {/* Custom Date Range Button */} + +
+
+ + {/* Right: Active Date Range Badge */} +
+ + + + {value === 'all' + ? 'Todo o Histórico' + : value === 'today' + ? `Hoje (${formatDatePtBr(currentRange.startDate)})` + : value === 'yesterday' + ? `Ontem (${formatDatePtBr(currentRange.startDate)})` + : `${formatDatePtBr(currentRange.startDate)} até ${formatDatePtBr(currentRange.endDate)}`} + + +
+ + {/* Modal / Dialog for Custom Date Range */} + {customModalOpen && ( +
+
+
+
+
+ +
+
+

+ Definir Período Personalizado +

+

+ Selecione a data de início e término desejadas. +

+
+
+ +
+ +
+
+
+ + setTempStart(e.target.value)} + required + className="w-full px-3 py-2 rounded-xl border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-brand shadow-xs" + /> +
+
+ + setTempEnd(e.target.value)} + required + className="w-full px-3 py-2 rounded-xl border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-brand shadow-xs" + /> +
+
+ + {/* Quick shortcut pills inside modal */} +
+ + Atalhos de Meses: + +
+ {[ + { + label: 'Últimos 30 dias', + days: 30, + }, + { + label: 'Últimos 60 dias', + days: 60, + }, + { + label: 'Últimos 90 dias', + days: 90, + }, + { + label: 'Últimos 180 dias', + days: 180, + }, + ].map((shortcut) => ( + + ))} +
+
+ +
+ + +
+
+
+
+ )} +
+ ) +} diff --git a/frontend/src/lib/periodUtils.ts b/frontend/src/lib/periodUtils.ts new file mode 100644 index 0000000..95dbd28 --- /dev/null +++ b/frontend/src/lib/periodUtils.ts @@ -0,0 +1,237 @@ +import type { Task } from '../types/task' + +export type PeriodPreset = + | 'today' + | 'yesterday' + | 'this_week' + | 'last_week' + | 'this_month' + | 'last_month' + | 'last_3_months' + | 'last_6_months' + | 'this_year' + | 'custom' + | 'all' + +export interface DateRange { + startDate: Date + endDate: Date + label: string +} + +export const PERIOD_PRESET_LABELS: Record = { + today: 'Hoje', + yesterday: 'Ontem', + this_week: 'Esta Semana', + last_week: 'Semana Passada', + this_month: 'Este Mês', + last_month: 'Mês Passado', + last_3_months: 'Últimos 3 Meses', + last_6_months: 'Últimos 6 Meses', + this_year: 'Este Ano', + custom: 'Personalizado', + all: 'Todo o Histórico', +} + +/** + * Returns startDate and endDate boundaries for a given preset. + */ +export function getDateRangeForPreset( + preset: PeriodPreset, + customStartStr?: string, + customEndStr?: string +): DateRange { + const now = new Date() + + switch (preset) { + case 'today': { + const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0) + const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999) + return { startDate: start, endDate: end, label: 'Hoje' } + } + case 'yesterday': { + const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 0, 0, 0, 0) + const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 23, 59, 59, 999) + return { startDate: start, endDate: end, label: 'Ontem' } + } + case 'this_week': { + const start = new Date(now) + const day = start.getDay() + const diffToMonday = (day === 0 ? -6 : 1) - day + start.setDate(start.getDate() + diffToMonday) + start.setHours(0, 0, 0, 0) + + const end = new Date(start) + end.setDate(end.getDate() + 6) + end.setHours(23, 59, 59, 999) + return { startDate: start, endDate: end, label: 'Esta Semana' } + } + case 'last_week': { + const start = new Date(now) + const day = start.getDay() + const diffToMonday = (day === 0 ? -6 : 1) - day - 7 + start.setDate(start.getDate() + diffToMonday) + start.setHours(0, 0, 0, 0) + + const end = new Date(start) + end.setDate(end.getDate() + 6) + end.setHours(23, 59, 59, 999) + return { startDate: start, endDate: end, label: 'Semana Passada' } + } + case 'this_month': { + const start = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0) + const end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999) + return { startDate: start, endDate: end, label: 'Este Mês' } + } + case 'last_month': { + const start = new Date(now.getFullYear(), now.getMonth() - 1, 1, 0, 0, 0, 0) + const end = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999) + return { startDate: start, endDate: end, label: 'Mês Passado' } + } + case 'last_3_months': { + // 3 months including current month (e.g. from 1st of month - 2 up to today/end of month) + const start = new Date(now.getFullYear(), now.getMonth() - 2, 1, 0, 0, 0, 0) + const end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999) + return { startDate: start, endDate: end, label: 'Últimos 3 Meses' } + } + case 'last_6_months': { + const start = new Date(now.getFullYear(), now.getMonth() - 5, 1, 0, 0, 0, 0) + const end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999) + return { startDate: start, endDate: end, label: 'Últimos 6 Meses' } + } + case 'this_year': { + const start = new Date(now.getFullYear(), 0, 1, 0, 0, 0, 0) + const end = new Date(now.getFullYear(), 11, 31, 23, 59, 59, 999) + return { startDate: start, endDate: end, label: `Ano ${now.getFullYear()}` } + } + case 'custom': { + if (customStartStr && customEndStr) { + const [sy, sm, sd] = customStartStr.split('-').map(Number) + const [ey, em, ed] = customEndStr.split('-').map(Number) + const start = new Date(sy, sm - 1, sd, 0, 0, 0, 0) + const end = new Date(ey, em - 1, ed, 23, 59, 59, 999) + return { + startDate: start, + endDate: end, + label: `${formatDatePtBr(start)} a ${formatDatePtBr(end)}`, + } + } + // Fallback to this month if missing custom values + const start = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0) + const end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999) + return { startDate: start, endDate: end, label: 'Personalizado' } + } + case 'all': + default: { + const start = new Date(2020, 0, 1, 0, 0, 0, 0) + const end = new Date(2035, 11, 31, 23, 59, 59, 999) + return { startDate: start, endDate: end, label: 'Todo o Histórico' } + } + } +} + +export function formatDatePtBr(d: Date): string { + const day = String(d.getDate()).padStart(2, '0') + const month = String(d.getMonth() + 1).padStart(2, '0') + const year = d.getFullYear() + return `${day}/${month}/${year}` +} + +export function formatDateYmd(d: Date): string { + const day = String(d.getDate()).padStart(2, '0') + const month = String(d.getMonth() + 1).padStart(2, '0') + const year = d.getFullYear() + return `${year}-${month}-${day}` +} + +/** + * Calculates the exact worked seconds for a given task overlapping the [rangeStart, rangeEnd] window. + * Also incorporates currently running timers if applicable. + */ +export function calculateTaskSecondsInRange( + task: Task, + rangeStart: Date, + rangeEnd: Date, + activeTaskId?: number, + activeSeconds?: number +): number { + if (!task.history || task.history.length === 0) { + if (activeTaskId === task.id && activeSeconds && activeSeconds > 0) { + return activeSeconds + } + return 0 + } + + let totalSec = 0 + const rangeStartMs = rangeStart.getTime() + const rangeEndMs = rangeEnd.getTime() + const nowMs = Date.now() + + for (const item of task.history) { + if (!item.startAt) continue + + const utcDateStr = item.startAt.endsWith('Z') ? item.startAt : `${item.startAt}Z` + const itemStartMs = new Date(utcDateStr).getTime() + if (isNaN(itemStartMs)) continue + + let itemEndMs: number + if (item.endAt) { + const utcEndStr = item.endAt.endsWith('Z') ? item.endAt : `${item.endAt}Z` + itemEndMs = new Date(utcEndStr).getTime() + } else { + // Currently active interval + itemEndMs = nowMs + } + + if (isNaN(itemEndMs) || itemEndMs <= itemStartMs) continue + + // Intersect [itemStartMs, itemEndMs] with [rangeStartMs, rangeEndMs] + const overlapStart = Math.max(itemStartMs, rangeStartMs) + const overlapEnd = Math.min(itemEndMs, rangeEndMs) + + if (overlapEnd > overlapStart) { + totalSec += Math.floor((overlapEnd - overlapStart) / 1000) + } + } + + return totalSec +} + +/** + * Checks whether a task was finished within [rangeStart, rangeEnd]. + */ +export function isTaskFinishedInRange(task: Task, rangeStart: Date, rangeEnd: Date): boolean { + if (task.status !== 'COMPLETED') return false + + if (task.finishedAt) { + const utcFinStr = task.finishedAt.endsWith('Z') ? task.finishedAt : `${task.finishedAt}Z` + const finDate = new Date(utcFinStr) + if (!isNaN(finDate.getTime())) { + return finDate >= rangeStart && finDate <= rangeEnd + } + } + + // Fallback: Check if the last history item was within the range + if (task.history && task.history.length > 0) { + const last = task.history[task.history.length - 1] + if (last.endAt) { + const utcEndStr = last.endAt.endsWith('Z') ? last.endAt : `${last.endAt}Z` + const endDate = new Date(utcEndStr) + if (!isNaN(endDate.getTime())) { + return endDate >= rangeStart && endDate <= rangeEnd + } + } + } + + return false +} + +/** + * Checks if a task had any active work, history, or creation in [rangeStart, rangeEnd]. + */ +export function hasTaskActivityInRange(task: Task, rangeStart: Date, rangeEnd: Date): boolean { + const seconds = calculateTaskSecondsInRange(task, rangeStart, rangeEnd) + if (seconds > 0) return true + if (isTaskFinishedInRange(task, rangeStart, rangeEnd)) return true + return false +} diff --git a/frontend/src/lib/userPreferences.ts b/frontend/src/lib/userPreferences.ts index ce08b4c..db82d6b 100644 --- a/frontend/src/lib/userPreferences.ts +++ b/frontend/src/lib/userPreferences.ts @@ -25,7 +25,18 @@ export type BiMetricType = | 'ACTIVE_TIMER' | 'TOP_TASKS' -export type BiPeriodFilter = 'ALL' | 'TODAY' | 'WEEK' | 'MONTH' +export type BiPeriodFilter = + | 'INHERIT' + | 'ALL' + | 'TODAY' + | 'YESTERDAY' + | 'WEEK' + | 'LAST_WEEK' + | 'MONTH' + | 'LAST_MONTH' + | 'LAST_3_MONTHS' + | 'LAST_6_MONTHS' + | 'YEAR' export type BiCardScope = 'INHERIT' | 'MINE' | 'TEAM' diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 25901e9..a14b3da 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -34,6 +34,14 @@ import type { Task, Project } from '../types/task' import { calculateTaskTotalSeconds, formatSecondsToHuman, formatSecondsToTimer } from '../lib/timeUtils' import { CreateTaskModal } from '../components/tasks/CreateTaskModal' import { GenerateReportModal } from '../components/reports/GenerateReportModal' +import { PeriodSelector } from '../components/dashboard/PeriodSelector' +import { + type PeriodPreset, + getDateRangeForPreset, + calculateTaskSecondsInRange, + isTaskFinishedInRange, + hasTaskActivityInRange, +} from '../lib/periodUtils' import { getUserWorkGoal, calculateUserPeriodWorkedSeconds, @@ -110,6 +118,32 @@ export const DashboardPage: React.FC = () => { ) } + // Period Preset State (Hoje, Esta Semana, Este Mês, Trimestre, Ano, etc.) + const [periodPreset, setPeriodPreset] = useState(() => { + return (localStorage.getItem('beetask_dashboard_period') as PeriodPreset) || 'this_month' + }) + const [customStart, setCustomStart] = useState(() => { + return localStorage.getItem('beetask_dashboard_custom_start') || undefined + }) + const [customEnd, setCustomEnd] = useState(() => { + return localStorage.getItem('beetask_dashboard_custom_end') || undefined + }) + + const handlePeriodChange = (preset: PeriodPreset, start?: string, end?: string) => { + setPeriodPreset(preset) + setCustomStart(start) + setCustomEnd(end) + try { + localStorage.setItem('beetask_dashboard_period', preset) + if (start) localStorage.setItem('beetask_dashboard_custom_start', start) + if (end) localStorage.setItem('beetask_dashboard_custom_end', end) + } catch (_) {} + } + + const activeDateRange = useMemo(() => { + return getDateRangeForPreset(periodPreset, customStart, customEnd) + }, [periodPreset, customStart, customEnd]) + const activeOrgId = activeOrg?.id const loadData = useCallback(async () => { @@ -161,15 +195,50 @@ export const DashboardPage: React.FC = () => { // Scope tasks based on viewScope const scopeTasks = viewScope === 'mine' ? myTasks : tasks - // KPIs dynamically calculated based on active scope + // KPIs dynamically calculated based on active scope AND active period const stats = useMemo(() => { - const inProgress = scopeTasks.filter((t) => t.status === 'IN_PROGRESS').length - const completed = scopeTasks.filter((t) => t.status === 'COMPLETED').length - const notStarted = scopeTasks.filter((t) => t.status === 'NOT_STARTED').length - const totalSeconds = scopeTasks.reduce((acc, t) => acc + calculateTaskTotalSeconds(t), 0) + const isAll = periodPreset === 'all' + const start = activeDateRange.startDate + const end = activeDateRange.endDate - const uniqueProjectIds = new Set(scopeTasks.map((t) => t.project?.id).filter(Boolean)) - const projectsCount = viewScope === 'mine' ? uniqueProjectIds.size : projects.length + let inProgress = 0 + let completed = 0 + let notStarted = 0 + let totalSeconds = 0 + const activeProjectIds = new Set() + + for (const t of scopeTasks) { + // Calculate period-accurate seconds + const sec = isAll + ? calculateTaskTotalSeconds(t) + : calculateTaskSecondsInRange(t, start, end, currentActiveTask?.id, activeSeconds) + totalSeconds += sec + + const hadActivity = isAll || sec > 0 || hasTaskActivityInRange(t, start, end) + + // Completed in period + if (isAll) { + if (t.status === 'COMPLETED') completed++ + else if (t.status === 'IN_PROGRESS') inProgress++ + else if (t.status === 'NOT_STARTED') notStarted++ + } else { + if (isTaskFinishedInRange(t, start, end)) { + completed++ + } else if (t.status === 'IN_PROGRESS' || sec > 0) { + inProgress++ + } else if (t.status === 'NOT_STARTED') { + notStarted++ + } + } + + if (hadActivity && t.project?.id) { + activeProjectIds.add(t.project.id) + } + } + + const projectsCount = isAll + ? (viewScope === 'mine' ? new Set(scopeTasks.map((t) => t.project?.id).filter(Boolean)).size : projects.length) + : activeProjectIds.size return { inProgress, @@ -177,8 +246,17 @@ export const DashboardPage: React.FC = () => { notStarted, totalSeconds, projectsCount, + periodLabel: activeDateRange.label, } - }, [scopeTasks, projects, viewScope]) + }, [ + scopeTasks, + projects, + viewScope, + periodPreset, + activeDateRange, + currentActiveTask?.id, + activeSeconds, + ]) // Work Goal Progress in Current Period const goalProgress = useMemo(() => { @@ -216,10 +294,19 @@ export const DashboardPage: React.FC = () => { } }, [tasks, workGoal, currentActiveTask?.id, activeSeconds]) - // Recent 5 tasks based on active scope + // Recent tasks based on active scope & period activity const recentTasks = useMemo(() => { + if (periodPreset === 'all') { + return [...scopeTasks].slice(0, 5) + } + const withActivity = scopeTasks.filter((t) => + hasTaskActivityInRange(t, activeDateRange.startDate, activeDateRange.endDate) + ) + if (withActivity.length > 0) { + return withActivity.slice(0, 5) + } return [...scopeTasks].slice(0, 5) - }, [scopeTasks]) + }, [scopeTasks, periodPreset, activeDateRange]) // Toggle timer on a task const handleToggleTimer = async (task: Task) => { @@ -425,6 +512,14 @@ export const DashboardPage: React.FC = () => { + {/* Global Period Selector */} + + {/* 4 Premium KPI Cards with shadow-panel & backdrop-blur */} {loading ? (
@@ -466,9 +561,11 @@ export const DashboardPage: React.FC = () => {

- {viewScope === 'mine' - ? `${stats.notStarted} na sua fila aguardando` - : `${stats.notStarted} tarefas aguardando no backlog`} + {periodPreset === 'all' + ? (viewScope === 'mine' + ? `${stats.notStarted} na sua fila aguardando` + : `${stats.notStarted} tarefas aguardando no backlog`) + : `Ativas no período (${stats.periodLabel})`}

@@ -500,9 +597,11 @@ export const DashboardPage: React.FC = () => {

- {viewScope === 'mine' - ? `de ${myTasks.length} demandas suas` - : `de ${tasks.length} demandas cadastradas`} + {periodPreset === 'all' + ? (viewScope === 'mine' + ? `de ${myTasks.length} demandas suas` + : `de ${tasks.length} demandas cadastradas`) + : `Entregas no período (${stats.periodLabel})`}

@@ -534,9 +633,7 @@ export const DashboardPage: React.FC = () => {

- {viewScope === 'mine' - ? `${formatSecondsToHuman(stats.totalSeconds)} auditadas por você` - : `${formatSecondsToHuman(stats.totalSeconds)} auditadas no time`} + {formatSecondsToHuman(stats.totalSeconds)} em {stats.periodLabel}

@@ -568,9 +665,11 @@ export const DashboardPage: React.FC = () => {

- {viewScope === 'mine' - ? 'Iniciativas com tarefas suas' - : 'Iniciativas ativas no workspace'} + {periodPreset === 'all' + ? (viewScope === 'mine' + ? 'Iniciativas com tarefas suas' + : 'Iniciativas ativas no workspace') + : `Com atuação no período (${stats.periodLabel})`}

@@ -808,9 +907,12 @@ export const DashboardPage: React.FC = () => { {/* Recent Sprint Tasks (Right 7 cols) */}
-

+

- {viewScope === 'mine' ? 'Minhas Demandas Recentes' : 'Demandas Recentes da Equipe'} + {viewScope === 'mine' ? 'Minhas Demandas' : 'Demandas da Equipe'} + + {stats.periodLabel} +

{ const { user, activeOrg } = useAuth() @@ -61,6 +63,28 @@ export const DashboardV2Page: React.FC = () => { 'team' const [viewScope, setViewScope] = useState<'mine' | 'team'>(initialScope) + // Global Period State + const [globalPeriod, setGlobalPeriod] = useState(() => { + return (localStorage.getItem('beetask_bi_period') as PeriodPreset) || 'this_month' + }) + const [globalCustomStart, setGlobalCustomStart] = useState(() => { + return localStorage.getItem('beetask_bi_custom_start') || undefined + }) + const [globalCustomEnd, setGlobalCustomEnd] = useState(() => { + return localStorage.getItem('beetask_bi_custom_end') || undefined + }) + + const handleGlobalPeriodChange = (preset: PeriodPreset, start?: string, end?: string) => { + setGlobalPeriod(preset) + setGlobalCustomStart(start) + setGlobalCustomEnd(end) + try { + localStorage.setItem('beetask_bi_period', preset) + if (start) localStorage.setItem('beetask_bi_custom_start', start) + if (end) localStorage.setItem('beetask_bi_custom_end', end) + } catch (_) {} + } + const handleScopeChange = (newScope: 'mine' | 'team') => { setViewScope(newScope) try { @@ -410,6 +434,14 @@ export const DashboardV2Page: React.FC = () => {
+ {/* Global Period Selector */} + + {/* Uniform Modular Grid */} {loading ? (
@@ -463,6 +495,9 @@ export const DashboardV2Page: React.FC = () => { activeSeconds={activeSeconds} workGoal={workGoal} globalScope={viewScope} + globalPeriod={globalPeriod} + globalCustomStart={globalCustomStart} + globalCustomEnd={globalCustomEnd} isBeingDragged={draggedCardId === card.id} isDragOver={dragOverCardId === card.id} onToggleTimer={handleToggleTimer}