| 1 | import { useCallback } from "react"; |
| 2 | import type { HeartbeatTask } from "./heartbeat.types"; |
| 3 | import { useHeartbeatT } from "./heartbeat.i18n"; |
| 4 | |
| 5 | const WEEKDAYS = [ |
| 6 | { key: "mon", labelKey: "heartbeat.weekdayMon" }, |
| 7 | { key: "tue", labelKey: "heartbeat.weekdayTue" }, |
| 8 | { key: "wed", labelKey: "heartbeat.weekdayWed" }, |
| 9 | { key: "thu", labelKey: "heartbeat.weekdayThu" }, |
| 10 | { key: "fri", labelKey: "heartbeat.weekdayFri" }, |
| 11 | { key: "sat", labelKey: "heartbeat.weekdaySat" }, |
| 12 | { key: "sun", labelKey: "heartbeat.weekdaySun" }, |
| 13 | ] as const; |
| 14 | |
| 15 | const ALL_WEEKDAYS = WEEKDAYS.map(w => w.key); |
| 16 | const DEFAULT_WEEKLY_DAY = "mon"; |
| 17 | |
| 18 | function defaultHeartbeatCycleDays(cycleType: string): string[] { |
| 19 | if (cycleType === "daily") return [...ALL_WEEKDAYS]; |
| 20 | if (cycleType === "weekly" || cycleType === "biweekly") return [DEFAULT_WEEKLY_DAY]; |
| 21 | return []; |
| 22 | } |
| 23 | |
| 24 | export function heartbeatBuildCycleInterval(cycleType: string, days: string[], time: string): string { |
| 25 | const base: Record<string, string> = { |
| 26 | daily: "24h", |
| 27 | weekly: "168h", |
| 28 | biweekly: "336h", |
| 29 | monthly: "720h", |
| 30 | yearly: "8760h", |
| 31 | }; |
| 32 | const selectedDays = days.filter(Boolean); |
| 33 | const isDailyWithSelection = cycleType === "daily" && selectedDays.length > 0 && selectedDays.length < 7; |
| 34 | const isDailyWithoutSelection = cycleType === "daily" && selectedDays.length === 0; |
| 35 | const effectiveType = isDailyWithoutSelection || isDailyWithSelection ? "weekly" : cycleType; |
| 36 | const scheduleDays = |
| 37 | (effectiveType === "weekly" || effectiveType === "biweekly") && selectedDays.length === 0 |
| 38 | ? defaultHeartbeatCycleDays(effectiveType) |
| 39 | : selectedDays; |
| 40 | |
| 41 | let suffix = `|${effectiveType}`; |
| 42 | if (effectiveType === "weekly" || effectiveType === "biweekly") { |
| 43 | suffix += `:${scheduleDays.join(",")}`; |
| 44 | } else if (effectiveType === "monthly") { |
| 45 | suffix += `:${scheduleDays[0] || "1"}`; |
| 46 | } else if (effectiveType === "yearly") { |
| 47 | suffix += `:${scheduleDays[0] || "1"}-${scheduleDays[1] || "1"}`; |
| 48 | } |
| 49 | suffix += `@${time}`; |
| 50 | return (base[cycleType] || "24h") + suffix; |
| 51 | } |
| 52 | |
| 53 | export function CycleEditor({ |
| 54 | draft, |
| 55 | setDraft, |
| 56 | cycleType, |
| 57 | }: { |
| 58 | draft: HeartbeatTask; |
| 59 | setDraft: (field: keyof HeartbeatTask, value: string | boolean) => void; |
| 60 | cycleType: "daily" | "weekly" | "biweekly" | "monthly" | "yearly"; |
| 61 | }) { |
| 62 | const t = useHeartbeatT(); |
| 63 | const cycleMatch = (draft.interval || "").match(/^(\d+)[smh]\|(daily|weekly|biweekly|monthly|yearly)(?::([^@]*))?(?:@(.*))?$/); |
| 64 | const cycleDays = cycleMatch?.[3] || ""; |
| 65 | // The interval is the controlled draft, including an unfinished time value. |
| 66 | const selectedDays = cycleDays ? cycleDays.split(",") : defaultHeartbeatCycleDays(cycleType); |
| 67 | const monthDay = cycleDays || "1"; |
| 68 | const yearMonth = cycleDays.split("-")[0] || "1"; |
| 69 | const yearDay = cycleDays.split("-")[1] || "1"; |
| 70 | const timeVal = cycleMatch?.[4] ?? "09:00"; |
| 71 | |
| 72 | // Build interval string when config changes |
| 73 | const buildInterval = useCallback((ct: string, days: string[], tm: string) => { |
| 74 | const base: Record<string, string> = { |
| 75 | daily: "24h", |
| 76 | weekly: "168h", |
| 77 | biweekly: "336h", |
| 78 | monthly: "720h", |
| 79 | yearly: "8760h", |
| 80 | }; |
| 81 | let suffix = `|${ct}`; |
| 82 | if (ct === "daily" || ct === "weekly" || ct === "biweekly") { |
| 83 | suffix += `:${days.join(",")}`; |
| 84 | } else if (ct === "monthly") { |
| 85 | suffix += `:${days[0] || "1"}`; |
| 86 | } else if (ct === "yearly") { |
| 87 | // days[0] = month, days[1] = day — each is a plain number, no dash |
| 88 | suffix += `:${days[0] || "1"}-${days[1] || "1"}`; |
| 89 | } |
| 90 | suffix += `@${tm}`; |
| 91 | return (base[ct] || "24h") + suffix; |
| 92 | }, []); |
| 93 | |
| 94 | const onDayToggle = (day: string) => { |
| 95 | const isWeeklyLike = cycleType === "weekly" || cycleType === "biweekly"; |
| 96 | if (selectedDays.includes(day) && selectedDays.length === 1 && isWeeklyLike) return; |
| 97 | const next = selectedDays.includes(day) ? selectedDays.filter((value) => value !== day) : [...selectedDays, day]; |
| 98 | setDraft("interval", buildInterval(cycleType, next, timeVal)); |
| 99 | }; |
| 100 | |
| 101 | const onMonthDayChange = useCallback((d: string) => { |
| 102 | setDraft("interval", buildInterval(cycleType, [d], timeVal)); |
| 103 | }, [buildInterval, cycleType, setDraft, timeVal]); |
| 104 | |
| 105 | const onYearMonthChange = useCallback((m: string) => { |
| 106 | setDraft("interval", buildInterval(cycleType, [m, yearDay], timeVal)); |
| 107 | }, [buildInterval, cycleType, setDraft, timeVal, yearDay]); |
| 108 | |
| 109 | const onYearDayChange = useCallback((d: string) => { |
| 110 | setDraft("interval", buildInterval(cycleType, [yearMonth, d], timeVal)); |
| 111 | }, [buildInterval, cycleType, setDraft, timeVal, yearMonth]); |
| 112 | |
| 113 | const onTimeChange = useCallback((tm: string) => { |
| 114 | const days = cycleType === "daily" || cycleType === "weekly" || cycleType === "biweekly" ? selectedDays |
| 115 | : cycleType === "monthly" ? [monthDay] |
| 116 | : cycleType === "yearly" ? [yearMonth, yearDay] |
| 117 | : []; |
| 118 | setDraft("interval", buildInterval(cycleType, days, tm)); |
| 119 | }, [buildInterval, cycleType, selectedDays, monthDay, yearMonth, yearDay, setDraft]); |
| 120 | |
| 121 | const MONTHS = Array.from({ length: 12 }, (_, i) => ({ |
| 122 | value: String(i + 1), |
| 123 | label: t("heartbeat.monthOption", { n: i + 1 }), |
| 124 | })); |
| 125 | const DAYS = Array.from({ length: 31 }, (_, i) => ({ |
| 126 | value: String(i + 1), |
| 127 | label: t("heartbeat.dayOption", { n: i + 1 }), |
| 128 | })); |
| 129 | |
| 130 | return ( |
| 131 | <div className="heartbeat-editor__cycle-wrap"> |
| 132 | <div className="heartbeat-editor__cycle-row"> |
| 133 | {cycleType === "monthly" && ( |
| 134 | <select |
| 135 | className="heartbeat-editor__freq-select" |
| 136 | value={monthDay} |
| 137 | onChange={(e) => onMonthDayChange(e.target.value)} |
| 138 | > |
| 139 | {DAYS.map((d) => ( |
| 140 | <option key={d.value} value={d.value}>{d.label}</option> |
| 141 | ))} |
| 142 | </select> |
| 143 | )} |
| 144 | |
| 145 | {cycleType === "yearly" && ( |
| 146 | <> |
| 147 | <select |
| 148 | className="heartbeat-editor__freq-select" |
| 149 | value={yearMonth} |
| 150 | onChange={(e) => onYearMonthChange(e.target.value)} |
| 151 | > |
| 152 | {MONTHS.map((m) => ( |
| 153 | <option key={m.value} value={m.value}>{m.label}</option> |
| 154 | ))} |
| 155 | </select> |
| 156 | <select |
| 157 | className="heartbeat-editor__freq-select" |
| 158 | value={yearDay} |
| 159 | onChange={(e) => onYearDayChange(e.target.value)} |
| 160 | > |
| 161 | {DAYS.map((d) => ( |
| 162 | <option key={d.value} value={d.value}>{d.label}</option> |
| 163 | ))} |
| 164 | </select> |
| 165 | </> |
| 166 | )} |
| 167 | |
| 168 | <input |
| 169 | className="heartbeat-editor__freq-input heartbeat-editor__freq-input--time" |
| 170 | type="time" |
| 171 | value={timeVal} |
| 172 | onChange={(e) => onTimeChange(e.target.value)} |
| 173 | /> |
| 174 | |
| 175 | {(cycleType === "weekly" || cycleType === "biweekly") && ( |
| 176 | <div className="set-seg"> |
| 177 | {WEEKDAYS.map((wd) => ( |
| 178 | <button |
| 179 | key={wd.key} |
| 180 | type="button" |
| 181 | className={`set-seg__btn${selectedDays.includes(wd.key) ? " set-seg__btn--on" : ""}`} |
| 182 | onClick={() => onDayToggle(wd.key)} |
| 183 | aria-pressed={selectedDays.includes(wd.key)} |
| 184 | > |
| 185 | {t(wd.labelKey)} |
| 186 | </button> |
| 187 | ))} |
| 188 | </div> |
| 189 | )} |
| 190 | </div> |
| 191 | </div> |
| 192 | ); |
| 193 | } |
| 194 |