feat: implement monthly targets and work-day-aware balance calculation

Add support for monthly and weekly targets with work-day selection:
- Users can now set targets as 'monthly' or 'weekly'
- Users select which days of the week they work
- Balance is calculated per working day, evenly distributed
- Target hours = (periodHours / workingDaysInPeriod)
- Corrections are applied at period level
- Daily balance tracking replaces weekly granularity

Database changes:
- Rename weekly_hours -> target_hours
- Add period_type (weekly|monthly, default=weekly)
- Add work_days (integer array of ISO weekdays, default=[1,2,3,4,5])
- Add constraints for valid period_type and non-empty work_days

Backend:
- Rewrite balance calculation in ClientTargetService
- Support monthly period enumeration
- Calculate per-day targets based on selected working days
- Update Zod schemas for new fields
- Update TypeScript types

Frontend:
- Add period type selector in target form (weekly/monthly)
- Add work days multi-checkbox selector
- Conditional start date input (week vs month)
- Update DashboardPage to show 'this period' instead of 'this week'
- Update ClientsPage to display working days and period type
- Support both weekly and monthly targets in UI

Migration:
- Add migration to extend client_targets table with new columns
- Backfill existing targets with default values (weekly, Mon-Fri)
This commit is contained in:
2026-02-24 18:11:45 +01:00
parent 5b7b8e47cb
commit 4f23c1c653
8 changed files with 481 additions and 145 deletions

View File

@@ -12,6 +12,7 @@ import type {
UpdateClientInput,
ClientTargetWithBalance,
CreateCorrectionInput,
CreateClientTargetInput,
} from '@/types';
// Convert a <input type="week"> value like "2026-W07" to the Monday date "2026-02-16"
@@ -42,6 +43,16 @@ function mondayToWeekInput(dateStr: string): string {
return `${year}-W${week.toString().padStart(2, '0')}`;
}
// Convert a YYYY-MM-DD that is the 1st of a month to "YYYY-MM" for month input
function dateToMonthInput(dateStr: string): string {
return dateStr.substring(0, 7);
}
// Convert "YYYY-MM" to "YYYY-MM-01"
function monthInputToDate(monthValue: string): string {
return `${monthValue}-01`;
}
function balanceLabel(seconds: number): { text: string; color: string } {
if (seconds === 0) return { text: '±0', color: 'text-gray-500' };
const abs = Math.abs(seconds);
@@ -50,6 +61,9 @@ function balanceLabel(seconds: number): { text: string; color: string } {
return { text, color };
}
const WEEKDAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const WEEKDAY_FULL_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
// Inline target panel shown inside each client card
function ClientTargetPanel({
target,
@@ -58,7 +72,7 @@ function ClientTargetPanel({
}: {
client: Client;
target: ClientTargetWithBalance | undefined;
onCreated: (weeklyHours: number, startDate: string) => Promise<void>;
onCreated: (input: CreateClientTargetInput) => Promise<void>;
onDeleted: () => Promise<void>;
}) {
const { addCorrection, deleteCorrection, updateTarget } = useClientTargets();
@@ -68,8 +82,10 @@ function ClientTargetPanel({
const [editing, setEditing] = useState(false);
// Create/edit form state
const [formPeriodType, setFormPeriodType] = useState<'weekly' | 'monthly'>('weekly');
const [formHours, setFormHours] = useState('');
const [formWeek, setFormWeek] = useState('');
const [formStartDate, setFormStartDate] = useState('');
const [formWorkDays, setFormWorkDays] = useState<number[]>([1, 2, 3, 4, 5]);
const [formError, setFormError] = useState<string | null>(null);
const [formSaving, setFormSaving] = useState(false);
@@ -82,12 +98,16 @@ function ClientTargetPanel({
const [corrSaving, setCorrSaving] = useState(false);
const openCreate = () => {
setFormHours('');
const today = new Date();
const day = today.getUTCDay() || 7;
const monday = new Date(today);
monday.setUTCDate(today.getUTCDate() - day + 1);
setFormWeek(mondayToWeekInput(monday.toISOString().split('T')[0]));
const mondayStr = monday.toISOString().split('T')[0];
setFormPeriodType('weekly');
setFormHours('');
setFormStartDate(mondayStr);
setFormWorkDays([1, 2, 3, 4, 5]);
setFormError(null);
setEditing(false);
setShowForm(true);
@@ -95,8 +115,10 @@ function ClientTargetPanel({
const openEdit = () => {
if (!target) return;
setFormHours(String(target.weeklyHours));
setFormWeek(mondayToWeekInput(target.startDate));
setFormPeriodType(target.periodType);
setFormHours(String(target.targetHours));
setFormStartDate(target.startDate);
setFormWorkDays(target.workDays);
setFormError(null);
setEditing(true);
setShowForm(true);
@@ -105,22 +127,44 @@ function ClientTargetPanel({
const handleFormSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormError(null);
const hours = parseFloat(formHours);
if (isNaN(hours) || hours <= 0 || hours > 168) {
setFormError('Weekly hours must be between 0 and 168');
const maxHours = formPeriodType === 'weekly' ? 168 : 744;
if (isNaN(hours) || hours <= 0 || hours > maxHours) {
setFormError(`Hours must be between 0 and ${maxHours}`);
return;
}
if (!formWeek) {
setFormError('Please select a start week');
if (!formStartDate) {
setFormError('Please select a start date');
return;
}
const startDate = weekInputToMonday(formWeek);
if (formWorkDays.length === 0) {
setFormError('Please select at least one working day');
return;
}
setFormSaving(true);
try {
if (editing && target) {
await updateTarget.mutateAsync({ id: target.id, input: { weeklyHours: hours, startDate } });
await updateTarget.mutateAsync({
id: target.id,
input: {
periodType: formPeriodType,
targetHours: hours,
startDate: formStartDate,
workDays: formWorkDays,
},
});
} else {
await onCreated(hours, startDate);
await onCreated({
clientId: target?.clientId || '',
periodType: formPeriodType,
targetHours: hours,
startDate: formStartDate,
workDays: formWorkDays,
});
}
setShowForm(false);
} catch (err) {
@@ -177,6 +221,12 @@ function ClientTargetPanel({
}
};
const toggleWorkDay = (day: number) => {
setFormWorkDays(prev =>
prev.includes(day) ? prev.filter(d => d !== day) : [...prev, day].sort()
);
};
if (!target && !showForm) {
return (
<div className="mt-3 pt-3 border-t border-gray-100">
@@ -185,7 +235,7 @@ function ClientTargetPanel({
className="flex items-center gap-1.5 text-xs text-primary-600 hover:text-primary-700 font-medium"
>
<Target className="h-3.5 w-3.5" />
Set weekly target
Set target
</button>
</div>
);
@@ -195,36 +245,99 @@ function ClientTargetPanel({
return (
<div className="mt-3 pt-3 border-t border-gray-100">
<p className="text-xs font-medium text-gray-700 mb-2">
{editing ? 'Edit target' : 'Set weekly target'}
{editing ? 'Edit target' : 'Set target'}
</p>
<form onSubmit={handleFormSubmit} className="space-y-2">
<form onSubmit={handleFormSubmit} className="space-y-3">
{formError && <p className="text-xs text-red-600">{formError}</p>}
<div className="flex gap-2">
<div className="flex-1">
<label className="block text-xs text-gray-500 mb-0.5">Hours/week</label>
<input
type="number"
value={formHours}
onChange={e => setFormHours(e.target.value)}
className="input text-sm py-1"
placeholder="e.g. 40"
min="0.5"
max="168"
step="0.5"
required
/>
</div>
<div className="flex-1">
<label className="block text-xs text-gray-500 mb-0.5">Starting week</label>
<input
type="week"
value={formWeek}
onChange={e => setFormWeek(e.target.value)}
className="input text-sm py-1"
required
/>
{/* Period Type Selector */}
<div>
<label className="block text-xs text-gray-500 mb-1">Period Type</label>
<div className="flex gap-2">
<label className="flex items-center gap-1.5 text-xs">
<input
type="radio"
name="periodType"
value="weekly"
checked={formPeriodType === 'weekly'}
onChange={() => setFormPeriodType('weekly')}
className="w-3 h-3"
/>
Weekly
</label>
<label className="flex items-center gap-1.5 text-xs">
<input
type="radio"
name="periodType"
value="monthly"
checked={formPeriodType === 'monthly'}
onChange={() => setFormPeriodType('monthly')}
className="w-3 h-3"
/>
Monthly
</label>
</div>
</div>
{/* Hours Input */}
<div>
<label className="block text-xs text-gray-500 mb-0.5">
Hours / {formPeriodType === 'weekly' ? 'week' : 'month'}
</label>
<input
type="number"
value={formHours}
onChange={e => setFormHours(e.target.value)}
className="input text-sm py-1 w-full"
placeholder={formPeriodType === 'weekly' ? 'e.g. 40' : 'e.g. 160'}
min="0.5"
max={formPeriodType === 'weekly' ? '168' : '744'}
step="0.5"
required
/>
</div>
{/* Start Date Input */}
<div>
<label className="block text-xs text-gray-500 mb-0.5">Starting</label>
{formPeriodType === 'weekly' ? (
<input
type="week"
value={mondayToWeekInput(formStartDate)}
onChange={e => setFormStartDate(weekInputToMonday(e.target.value))}
className="input text-sm py-1 w-full"
required
/>
) : (
<input
type="month"
value={dateToMonthInput(formStartDate)}
onChange={e => setFormStartDate(monthInputToDate(e.target.value))}
className="input text-sm py-1 w-full"
required
/>
)}
</div>
{/* Work Days Selector */}
<div>
<label className="block text-xs text-gray-500 mb-1">Working Days</label>
<div className="grid grid-cols-7 gap-1">
{WEEKDAY_FULL_NAMES.map((name, idx) => (
<label key={idx + 1} className="flex items-center justify-center" title={name}>
<input
type="checkbox"
checked={formWorkDays.includes(idx + 1)}
onChange={() => toggleWorkDay(idx + 1)}
className="w-3 h-3"
/>
<span className="text-xs ml-1">{WEEKDAY_NAMES[idx]}</span>
</label>
))}
</div>
</div>
{/* Form Actions */}
<div className="flex gap-2 justify-end">
<button
type="button"
@@ -248,6 +361,7 @@ function ClientTargetPanel({
// Target exists — show summary + expandable details
const balance = balanceLabel(target!.totalBalanceSeconds);
const workDayLabel = target!.workDays.map(d => WEEKDAY_NAMES[d - 1]).join(' ');
return (
<div className="mt-3 pt-3 border-t border-gray-100">
@@ -256,8 +370,10 @@ function ClientTargetPanel({
<div className="flex items-center gap-2">
<Target className="h-3.5 w-3.5 text-gray-400 shrink-0" />
<span className="text-xs text-gray-600">
<span className="font-medium">{target!.weeklyHours}h</span>/week
<span className="font-medium">{target!.targetHours}h</span>
/{target!.periodType === 'weekly' ? 'week' : 'month'}
</span>
<span className="text-xs text-gray-500">({workDayLabel})</span>
<span className={`text-xs font-semibold ${balance.color}`}>{balance.text}</span>
</div>
<div className="flex items-center gap-1">
@@ -324,7 +440,7 @@ function ClientTargetPanel({
type="date"
value={corrDate}
onChange={e => setCorrDate(e.target.value)}
className="input text-xs py-1"
className="input text-xs py-1 w-full"
required
/>
</div>
@@ -347,7 +463,7 @@ function ClientTargetPanel({
type="text"
value={corrDesc}
onChange={e => setCorrDesc(e.target.value)}
className="input text-xs py-1"
className="input text-xs py-1 w-full"
placeholder="e.g. Public holiday"
maxLength={255}
/>
@@ -531,8 +647,8 @@ export function ClientsPage() {
<ClientTargetPanel
client={client}
target={target}
onCreated={async (weeklyHours, startDate) => {
await createTarget.mutateAsync({ clientId: client.id, weeklyHours, startDate });
onCreated={async (input) => {
await createTarget.mutateAsync({ ...input, clientId: client.id });
}}
onDeleted={async () => {
if (target) await deleteTarget.mutateAsync(target.id);

View File

@@ -59,7 +59,7 @@ export function DashboardPage() {
return total + calculateDuration(entry.startTime, entry.endTime, entry.breakMinutes);
}, 0) || 0;
const targetsWithData = targets?.filter(t => t.weeks.length > 0) ?? [];
const targetsWithData = targets?.filter(t => t.days.length > 0) ?? [];
return (
<div className="space-y-6">
@@ -103,12 +103,12 @@ export function DashboardPage() {
/>
</div>
{/* Overtime / Targets Widget */}
{/* Overtime / Targets Widget */}
{targetsWithData.length > 0 && (
<div className="card">
<div className="flex items-center gap-2 mb-4">
<Target className="h-5 w-5 text-primary-600" />
<h2 className="text-lg font-semibold text-gray-900">Weekly Targets</h2>
<h2 className="text-lg font-semibold text-gray-900">Targets</h2>
</div>
<div className="space-y-3">
{targetsWithData.map(target => {
@@ -116,8 +116,9 @@ export function DashboardPage() {
const absBalance = Math.abs(balance);
const isOver = balance > 0;
const isEven = balance === 0;
const currentWeekTracked = formatDurationHoursMinutes(target.currentWeekTrackedSeconds);
const currentWeekTarget = formatDurationHoursMinutes(target.currentWeekTargetSeconds);
const currentPeriodTracked = formatDurationHoursMinutes(target.currentPeriodTrackedSeconds);
const currentPeriodTarget = formatDurationHoursMinutes(target.currentPeriodTargetSeconds);
const periodLabel = target.periodType === 'weekly' ? 'week' : 'month';
return (
<div
@@ -127,7 +128,7 @@ export function DashboardPage() {
<div>
<p className="text-sm font-medium text-gray-900">{target.clientName}</p>
<p className="text-xs text-gray-500 mt-0.5">
This week: {currentWeekTracked} / {currentWeekTarget}
This {periodLabel}: {currentPeriodTracked} / {currentPeriodTarget}
</p>
</div>
<div className="text-right">

View File

@@ -155,6 +155,13 @@ export interface BalanceCorrection {
deletedAt: string | null;
}
export interface DayBalance {
date: string; // YYYY-MM-DD
trackedSeconds: number;
targetSeconds: number;
balanceSeconds: number;
}
export interface WeekBalance {
weekStart: string; // YYYY-MM-DD (Monday)
weekEnd: string; // YYYY-MM-DD (Sunday)
@@ -169,25 +176,31 @@ export interface ClientTargetWithBalance {
clientId: string;
clientName: string;
userId: string;
weeklyHours: number;
periodType: 'weekly' | 'monthly';
targetHours: number;
workDays: number[];
startDate: string; // YYYY-MM-DD
createdAt: string;
updatedAt: string;
corrections: BalanceCorrection[];
totalBalanceSeconds: number;
currentWeekTrackedSeconds: number;
currentWeekTargetSeconds: number;
weeks: WeekBalance[];
currentPeriodTrackedSeconds: number;
currentPeriodTargetSeconds: number;
days: DayBalance[];
}
export interface CreateClientTargetInput {
clientId: string;
weeklyHours: number;
periodType: 'weekly' | 'monthly';
targetHours: number;
workDays: number[];
startDate: string; // YYYY-MM-DD
}
export interface UpdateClientTargetInput {
weeklyHours?: number;
periodType?: 'weekly' | 'monthly';
targetHours?: number;
workDays?: number[];
startDate?: string;
}