@thecodecodex/tools 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,171 @@
1
+ // Planejador de férias CLT: feriados nacionais, regra de início (CLT art. 134, §3º) e fracionamento (art. 134, §1º).
2
+ // Datas são tratadas como dias do calendário em UTC ("YYYY-MM-DD") para não sofrer com fuso/horário de verão.
3
+
4
+ import { stateHolidaysFor, type Uf } from "./state-holidays"
5
+
6
+ export type Iso = string
7
+
8
+ const DAY = 86_400_000
9
+ export const toIso = (date: Date): Iso => date.toISOString().slice(0, 10)
10
+ export const fromIso = (iso: Iso): Date => new Date(`${iso}T00:00:00Z`)
11
+ export const addDays = (iso: Iso, days: number): Iso => toIso(new Date(fromIso(iso).getTime() + days * DAY))
12
+ const weekday = (iso: Iso) => fromIso(iso).getUTCDay() // 0 = domingo
13
+
14
+ // Domingo de Páscoa (algoritmo de Meeus/Jones/Butcher, calendário gregoriano)
15
+ export function easter(year: number): Iso {
16
+ const a = year % 19
17
+ const b = Math.floor(year / 100)
18
+ const c = year % 100
19
+ const d = Math.floor(b / 4)
20
+ const e = b % 4
21
+ const f = Math.floor((b + 8) / 25)
22
+ const g = Math.floor((b - f + 1) / 3)
23
+ const h = (19 * a + b - d - g + 15) % 30
24
+ const i = Math.floor(c / 4)
25
+ const k = c % 4
26
+ const l = (32 + 2 * e + 2 * i - h - k) % 7
27
+ const m = Math.floor((a + 11 * h + 22 * l) / 451)
28
+ const month = Math.floor((h + l - 7 * m + 114) / 31)
29
+ const day = ((h + l - 7 * m + 114) % 31) + 1
30
+ return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`
31
+ }
32
+
33
+ export type HolidayKind = "national" | "optional" | "state" | "custom"
34
+
35
+ export interface Holiday {
36
+ date: Iso
37
+ name: string
38
+ kind: HolidayKind
39
+ // feriados estaduais: lei e fonte
40
+ law?: string
41
+ source?: string
42
+ }
43
+
44
+ // Feriados nacionais (Lei 662/1949, Lei 6.802/1980, Lei 14.759/2023) e pontos facultativos usuais
45
+ export function nationalHolidays(year: number): Holiday[] {
46
+ const e = easter(year)
47
+ const fixed: [string, string][] = [
48
+ ["01-01", "Confraternização Universal"],
49
+ ["04-21", "Tiradentes"],
50
+ ["05-01", "Dia do Trabalho"],
51
+ ["09-07", "Independência"],
52
+ ["10-12", "Nossa Senhora Aparecida"],
53
+ ["11-02", "Finados"],
54
+ ["11-15", "Proclamação da República"],
55
+ ["11-20", "Consciência Negra"],
56
+ ["12-25", "Natal"],
57
+ ]
58
+ return [
59
+ ...fixed.map(([md, name]) => ({ date: `${year}-${md}`, name, kind: "national" as const })),
60
+ { date: addDays(e, -2), name: "Sexta-feira Santa", kind: "national" as const },
61
+ { date: addDays(e, -48), name: "Carnaval (segunda)", kind: "optional" as const },
62
+ { date: addDays(e, -47), name: "Carnaval (terça)", kind: "optional" as const },
63
+ { date: addDays(e, 60), name: "Corpus Christi", kind: "optional" as const },
64
+ ].sort((a, b) => a.date.localeCompare(b.date))
65
+ }
66
+
67
+ // 5x2: folga sábado e domingo (repouso semanal no domingo). 6x1: folga só no domingo.
68
+ export type Schedule = "5x2" | "6x1"
69
+
70
+ export interface PlannerInput {
71
+ year: number
72
+ days: number
73
+ schedule: Schedule
74
+ // pontos facultativos (Carnaval, Corpus Christi) contam como folga?
75
+ includeOptional: boolean
76
+ // feriados locais ou folgas já combinadas
77
+ extraDaysOff: Iso[]
78
+ // estado para incluir os feriados estaduais
79
+ uf?: Uf | undefined
80
+ }
81
+
82
+ export interface Suggestion {
83
+ start: Iso
84
+ end: Iso
85
+ // descanso contínuo, incluindo fins de semana e feriados colados
86
+ restStart: Iso
87
+ restEnd: Iso
88
+ restDays: number
89
+ holidays: Holiday[]
90
+ }
91
+
92
+ export function holidaysFor(input: Pick<PlannerInput, "year" | "includeOptional" | "extraDaysOff" | "uf">): Holiday[] {
93
+ const base = [...nationalHolidays(input.year), ...nationalHolidays(input.year + 1).filter((h) => h.date.endsWith("-01-01"))]
94
+ const state = input.uf ? stateHolidaysFor(input.uf, input.year).map((h) => ({ ...h, kind: "state" as const })) : []
95
+ return [
96
+ ...base.filter((h) => input.includeOptional || h.kind !== "optional"),
97
+ ...state,
98
+ ...input.extraDaysOff.map((date) => ({ date, name: "Folga local", kind: "custom" as const })),
99
+ ]
100
+ }
101
+
102
+ export function makeDayOff(input: Pick<PlannerInput, "schedule">, holidays: Holiday[]) {
103
+ const set = new Set(holidays.map((h) => h.date))
104
+ const isWeeklyRest = (iso: Iso) => (input.schedule === "5x2" ? [0, 6].includes(weekday(iso)) : weekday(iso) === 0)
105
+ // repouso semanal REMUNERADO é o domingo nas duas escalas (no 5x2 o sábado é folga compensada, não DSR)
106
+ const isPaidRestOrHoliday = (iso: Iso) => weekday(iso) === 0 || set.has(iso)
107
+ return { isOff: (iso: Iso) => isWeeklyRest(iso) || set.has(iso), isWeeklyRest, isPaidRestOrHoliday, isHoliday: (iso: Iso) => set.has(iso) }
108
+ }
109
+
110
+ // CLT art. 134, §3º: as férias não podem começar nos 2 dias que antecedem feriado ou repouso semanal remunerado;
111
+ // também não faz sentido começar num dia que já é folga
112
+ export function canStart(iso: Iso, isOff: (iso: Iso) => boolean, isPaidRestOrHoliday: (iso: Iso) => boolean): boolean {
113
+ return !isOff(iso) && !isPaidRestOrHoliday(addDays(iso, 1)) && !isPaidRestOrHoliday(addDays(iso, 2))
114
+ }
115
+
116
+ export function suggest(input: PlannerInput, limit = 5, exclude: { start: Iso, end: Iso }[] = []): Suggestion[] {
117
+ const holidays = holidaysFor(input)
118
+ const { isOff, isHoliday, isPaidRestOrHoliday } = makeDayOff(input, holidays)
119
+ const days = Math.min(30, Math.max(1, Math.round(input.days)))
120
+ const results: Suggestion[] = []
121
+ for (let start = `${input.year}-01-01`; start <= `${input.year}-12-31`; start = addDays(start, 1)) {
122
+ if (!canStart(start, isOff, isPaidRestOrHoliday)) continue
123
+ const end = addDays(start, days - 1)
124
+ // não pode sobrepor períodos já escolhidos (com 1 dia de folga entre eles)
125
+ if (exclude.some((p) => !(end < addDays(p.start, -1) || start > addDays(p.end, 1)))) continue
126
+ let restStart = start
127
+ while (isOff(addDays(restStart, -1))) restStart = addDays(restStart, -1)
128
+ let restEnd = end
129
+ while (isOff(addDays(restEnd, 1))) restEnd = addDays(restEnd, 1)
130
+ const restDays = Math.round((fromIso(restEnd).getTime() - fromIso(restStart).getTime()) / DAY) + 1
131
+ const inside = holidays.filter((h) => h.date >= restStart && h.date <= restEnd && isHoliday(h.date))
132
+ results.push({ start, end, restStart, restEnd, restDays, holidays: inside })
133
+ }
134
+ // mais descanso primeiro; empate: mais feriados aproveitados, depois a data mais cedo
135
+ results.sort((a, b) => b.restDays - a.restDays || b.holidays.length - a.holidays.length || a.start.localeCompare(b.start))
136
+ // evita sugestões quase iguais (mesmo bloco de descanso)
137
+ const picked: Suggestion[] = []
138
+ for (const s of results) {
139
+ if (picked.some((p) => !(s.restEnd < p.restStart || s.restStart > p.restEnd))) continue
140
+ picked.push(s)
141
+ if (picked.length === limit) break
142
+ }
143
+ return picked
144
+ }
145
+
146
+ // Fracionamento (CLT art. 134, §1º): até 3 períodos, um com 14+ dias e os demais com 5+ dias
147
+ export function isValidSplit(parts: number[]): boolean {
148
+ if (parts.length < 1 || parts.length > 3) return false
149
+ if (parts.some((p) => p < 5)) return false
150
+ return parts.length === 1 || parts.some((p) => p >= 14)
151
+ }
152
+
153
+ export const SPLITS: number[][] = [[30], [15, 15], [20, 10], [14, 8, 8], [14, 11, 5], [20, 5, 5], [14, 6, 10]].filter(isValidSplit)
154
+
155
+ export interface SplitPlan {
156
+ parts: Suggestion[]
157
+ restDays: number
158
+ }
159
+
160
+ // Escolhe o melhor período para cada parte, da maior para a menor, sem sobreposição
161
+ export function planSplit(input: Omit<PlannerInput, "days">, parts: number[]): SplitPlan | undefined {
162
+ if (!isValidSplit(parts)) return undefined
163
+ const chosen: Suggestion[] = []
164
+ for (const days of [...parts].sort((a, b) => b - a)) {
165
+ const best = suggest({ ...input, days }, 1, chosen)[0]
166
+ if (!best) return undefined
167
+ chosen.push(best)
168
+ }
169
+ chosen.sort((a, b) => a.start.localeCompare(b.start))
170
+ return { parts: chosen, restDays: chosen.reduce((sum, s) => sum + s.restDays, 0) }
171
+ }