feat(dashboard): add comprehensive period selector and time tracking to dashboard and dashboard v2

This commit is contained in:
Gabriel Menezes 2026-11-02 10:24:37 -04:00
parent 977c7ffd7a
commit 49d010eb71
7 changed files with 809 additions and 70 deletions

View file

@ -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<BiUniformCardProps> = ({
activeSeconds,
workGoal,
globalScope = 'team',
globalPeriod = 'this_month',
globalCustomStart,
globalCustomEnd,
isBeingDragged = false,
isDragOver = false,
onToggleTimer,
@ -138,7 +145,6 @@ export const BiUniformCard: React.FC<BiUniformCardProps> = ({
}) => {
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<BiUniformCardProps> = ({
: '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<BiUniformCardProps> = ({
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<string, string> = {
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<BiUniformCardProps> = ({
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 (
<div className="space-y-1 my-auto">
{top2.length === 0 ? (
<p className="text-[11px] text-muted italic">Nenhuma tarefa encontrada.</p>
<p className="text-[11px] text-muted italic">Nenhuma atividade no período.</p>
) : (
top2.map((t, i) => (
top2.map(({ task: t, sec }, i) => (
<div key={t.id} className="flex items-center justify-between text-[10px] gap-2">
<span className="truncate text-foreground font-semibold">
<strong className="text-muted mr-1">#{i + 1}</strong> {t.title}
</span>
<span className="font-mono text-muted shrink-0">
{formatSecondsToTimer(calculateTaskTotalSeconds(t))}
{formatSecondsToTimer(sec)}
</span>
</div>
))
@ -576,7 +608,7 @@ export const BiUniformCard: React.FC<BiUniformCardProps> = ({
</span>
<span className={`px-1.5 py-0.2 rounded font-semibold border ${theme.badge}`}>
{periodLabels[card.period]}
{periodBadgeLabel}
</span>
{projectName && (

View file

@ -258,10 +258,17 @@ export const CreateBiCardModal: React.FC<CreateBiCardModalProps> = ({
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' },
]}
/>
</div>

View file

@ -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<PeriodSelectorProps> = ({
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<HTMLDivElement>(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 (
<div className={`flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-2.5 sm:p-3 rounded-2xl bg-surface/90 border border-border-soft backdrop-blur-md shadow-panel ${className}`}>
{/* Left: Buttons & Presets */}
<div className="flex items-center gap-1.5 flex-wrap">
<div className="flex items-center gap-1 text-xs font-bold text-muted mr-1">
<Calendar className="w-3.5 h-3.5 text-accent" />
<span className="hidden md:inline">Período:</span>
</div>
<div className="inline-flex p-1 rounded-xl bg-surface-sunken border border-border-soft shadow-inner flex-wrap gap-1">
{PRIMARY_PRESETS.map((p) => {
const active = value === p
return (
<button
key={p}
type="button"
onClick={() => handleSelectPreset(p)}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer ${
active
? 'bg-brand text-white shadow-md shadow-brand/25'
: 'text-muted hover:text-foreground hover:bg-surface/60'
}`}
>
{PERIOD_PRESET_LABELS[p]}
</button>
)
})}
{/* More Presets Dropdown */}
<div ref={dropdownRef} className="relative">
<button
type="button"
onClick={() => setDropdownOpen(!dropdownOpen)}
className={`flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer ${
isSecondaryActive
? 'bg-brand text-white shadow-md shadow-brand/25'
: 'text-muted hover:text-foreground hover:bg-surface/60'
}`}
>
<span>{isSecondaryActive ? PERIOD_PRESET_LABELS[value] : 'Outros'}</span>
<ChevronDown className="w-3.5 h-3.5" />
</button>
{dropdownOpen && (
<div className="absolute left-0 sm:right-0 sm:left-auto top-full mt-1.5 w-48 rounded-2xl bg-surface/95 backdrop-blur-xl border border-border-soft p-1.5 shadow-2xl z-50 animate-in fade-in zoom-in-95 duration-150 space-y-0.5">
{SECONDARY_PRESETS.map((p) => {
const active = value === p
return (
<button
key={p}
type="button"
onClick={() => handleSelectPreset(p)}
className={`w-full flex items-center justify-between px-3 py-2 rounded-xl text-xs font-semibold text-left transition-colors cursor-pointer ${
active
? 'bg-brand/10 text-brand font-bold'
: 'text-foreground hover:bg-surface-raised'
}`}
>
<span>{PERIOD_PRESET_LABELS[p]}</span>
{active && <Check className="w-3.5 h-3.5 text-brand" />}
</button>
)
})}
</div>
)}
</div>
{/* Custom Date Range Button */}
<button
type="button"
onClick={() => {
setTempStart(customStart || defaultDates().start)
setTempEnd(customEnd || defaultDates().end)
setCustomModalOpen(true)
}}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer ${
value === 'custom'
? 'bg-accent text-accent-foreground shadow-md shadow-accent/25'
: 'text-muted hover:text-foreground hover:bg-surface/60'
}`}
>
<CalendarRange className="w-3.5 h-3.5" />
<span>Personalizado</span>
</button>
</div>
</div>
{/* Right: Active Date Range Badge */}
<div className="flex items-center gap-2 self-end sm:self-center">
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-xl bg-surface-raised border border-border-soft text-xs font-mono font-semibold text-foreground/90 shadow-2xs">
<Clock className="w-3 h-3 text-brand shrink-0" />
<span>
{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)}`}
</span>
</span>
</div>
{/* Modal / Dialog for Custom Date Range */}
{customModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs animate-in fade-in duration-200">
<div
className="w-full max-w-md bg-surface border border-border-soft rounded-3xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200"
role="dialog"
>
<div className="flex items-center justify-between p-5 border-b border-border-soft">
<div className="flex items-center gap-2.5">
<div className="w-9 h-9 rounded-2xl bg-accent/15 text-accent border border-accent/25 flex items-center justify-center">
<CalendarRange className="w-4 h-4" />
</div>
<div>
<h3 className="text-sm font-extrabold text-foreground">
Definir Período Personalizado
</h3>
<p className="text-[11px] text-muted">
Selecione a data de início e término desejadas.
</p>
</div>
</div>
<button
type="button"
onClick={() => setCustomModalOpen(false)}
className="p-1.5 rounded-xl text-muted hover:text-foreground hover:bg-surface-raised transition-colors cursor-pointer"
>
✕
</button>
</div>
<form onSubmit={handleApplyCustom} className="p-5 space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">Data Inicial</label>
<input
type="date"
value={tempStart}
onChange={(e) => 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"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">Data Final</label>
<input
type="date"
value={tempEnd}
onChange={(e) => 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"
/>
</div>
</div>
{/* Quick shortcut pills inside modal */}
<div className="pt-2 border-t border-border-soft/60 space-y-1.5">
<span className="text-[10px] font-bold text-muted uppercase tracking-wider block">
Atalhos de Meses:
</span>
<div className="flex flex-wrap gap-1.5">
{[
{
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) => (
<button
key={shortcut.label}
type="button"
onClick={() => {
const now = new Date()
const s = new Date(now.getTime() - shortcut.days * 24 * 60 * 60 * 1000)
setTempStart(formatDateYmd(s))
setTempEnd(formatDateYmd(now))
}}
className="px-2.5 py-1 rounded-lg text-[11px] font-semibold bg-surface-raised hover:bg-brand/10 hover:text-brand border border-border-soft transition-colors cursor-pointer"
>
{shortcut.label}
</button>
))}
</div>
</div>
<div className="flex items-center justify-end gap-2.5 pt-3 border-t border-border-soft">
<button
type="button"
onClick={() => setCustomModalOpen(false)}
className="px-4 py-2 rounded-xl text-xs font-semibold text-muted hover:text-foreground hover:bg-surface-raised transition-colors cursor-pointer"
>
Cancelar
</button>
<button
type="submit"
className="px-5 py-2 rounded-xl bg-brand hover:bg-brand-strong text-white text-xs font-bold shadow-md shadow-brand/20 transition-all cursor-pointer"
>
Aplicar Período
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}

View file

@ -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<PeriodPreset, string> = {
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
}

View file

@ -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'

View file

@ -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<PeriodPreset>(() => {
return (localStorage.getItem('beetask_dashboard_period') as PeriodPreset) || 'this_month'
})
const [customStart, setCustomStart] = useState<string | undefined>(() => {
return localStorage.getItem('beetask_dashboard_custom_start') || undefined
})
const [customEnd, setCustomEnd] = useState<string | undefined>(() => {
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<number>()
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 = () => {
</div>
</div>
{/* Global Period Selector */}
<PeriodSelector
value={periodPreset}
customStart={customStart}
customEnd={customEnd}
onChange={handlePeriodChange}
/>
{/* 4 Premium KPI Cards with shadow-panel & backdrop-blur */}
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
@ -466,9 +561,11 @@ export const DashboardPage: React.FC = () => {
</span>
</div>
<p className="text-[11px] text-muted font-medium mt-1">
{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})`}
</p>
</div>
</div>
@ -500,9 +597,11 @@ export const DashboardPage: React.FC = () => {
</span>
</div>
<p className="text-[11px] text-muted font-medium mt-1">
{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})`}
</p>
</div>
</div>
@ -534,9 +633,7 @@ export const DashboardPage: React.FC = () => {
</span>
</div>
<p className="text-[11px] text-muted font-medium mt-1">
{viewScope === 'mine'
? `${formatSecondsToHuman(stats.totalSeconds)} auditadas por você`
: `${formatSecondsToHuman(stats.totalSeconds)} auditadas no time`}
{formatSecondsToHuman(stats.totalSeconds)} em {stats.periodLabel}
</p>
</div>
</div>
@ -568,9 +665,11 @@ export const DashboardPage: React.FC = () => {
</span>
</div>
<p className="text-[11px] text-muted font-medium mt-1">
{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})`}
</p>
</div>
</div>
@ -808,9 +907,12 @@ export const DashboardPage: React.FC = () => {
{/* Recent Sprint Tasks (Right 7 cols) */}
<div className="lg:col-span-7 space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-foreground flex items-center gap-2">
<h2 className="text-lg font-bold text-foreground flex items-center gap-2 flex-wrap">
<Zap className="w-4 h-4 text-accent" />
{viewScope === 'mine' ? 'Minhas Demandas Recentes' : 'Demandas Recentes da Equipe'}
<span>{viewScope === 'mine' ? 'Minhas Demandas' : 'Demandas da Equipe'}</span>
<span className="text-xs font-semibold px-2 py-0.5 rounded-full bg-brand-soft text-brand border border-brand/20">
{stats.periodLabel}
</span>
</h2>
<Link
to="/tasks"

View file

@ -26,8 +26,10 @@ import {
type DashboardV2Card,
} from '../lib/userPreferences'
import { getUserWorkGoal, type WorkGoal } from '../lib/userGoals'
import { type PeriodPreset } from '../lib/periodUtils'
import { BiUniformCard } from '../components/dashboard/BiUniformCard'
import { CreateBiCardModal } from '../components/dashboard/CreateBiCardModal'
import { PeriodSelector } from '../components/dashboard/PeriodSelector'
export const DashboardV2Page: React.FC = () => {
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<PeriodPreset>(() => {
return (localStorage.getItem('beetask_bi_period') as PeriodPreset) || 'this_month'
})
const [globalCustomStart, setGlobalCustomStart] = useState<string | undefined>(() => {
return localStorage.getItem('beetask_bi_custom_start') || undefined
})
const [globalCustomEnd, setGlobalCustomEnd] = useState<string | undefined>(() => {
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 = () => {
</div>
</div>
{/* Global Period Selector */}
<PeriodSelector
value={globalPeriod}
customStart={globalCustomStart}
customEnd={globalCustomEnd}
onChange={handleGlobalPeriodChange}
/>
{/* Uniform Modular Grid */}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@ -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}