25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

423 lines
16 KiB

  1. // assets/scripts/calendar.js
  2. import { esc, createTranslator, FADE_MS } from './utils.js';
  3. const t = createTranslator('TT');
  4. class WeekCalendar {
  5. constructor() {
  6. this.nav = document.querySelector('.week-nav');
  7. this.daysContainer = document.querySelector('.week-nav__days');
  8. this.calBtn = document.querySelector('.week-nav__cal');
  9. this.prevBtn = document.querySelector('.week-nav__arrow--prev');
  10. this.nextBtn = document.querySelector('.week-nav__arrow--next');
  11. this.header = document.querySelector('.tt-header');
  12. const raw = this.nav?.dataset.activeDate;
  13. this.selectedDate = raw ? new Date(raw + 'T00:00:00') : new Date();
  14. this.selectedDate.setHours(0, 0, 0, 0);
  15. this.displayMonday = this.getMonday(this.selectedDate);
  16. this.today = new Date();
  17. this.today.setHours(0, 0, 0, 0);
  18. this.monthOpen = false;
  19. this.monthDate = new Date(this.selectedDate);
  20. this.monthEl = null;
  21. this.dayCounts = {};
  22. if (!this.nav) return;
  23. this.init();
  24. }
  25. get months() { return t('months') || []; }
  26. get monthsShort() { return t('monthsShort') || []; }
  27. get weekdays() { return t('weekdays') || []; }
  28. get weekdaysShort() { return t('weekdaysShort') || []; }
  29. init() {
  30. this.prevBtn?.addEventListener('click', e => { e.preventDefault(); this.navigateWeek(-1); });
  31. this.nextBtn?.addEventListener('click', e => { e.preventDefault(); this.navigateWeek(1); });
  32. this.calBtn?.addEventListener('click', e => { e.preventDefault(); this.toggleMonth(); });
  33. this.daysContainer?.addEventListener('click', e => {
  34. const dayEl = e.target.closest('.week-nav__day');
  35. if (!dayEl) return;
  36. e.preventDefault();
  37. const dateStr = dayEl.dataset.date;
  38. if (dateStr) this.goToDate(new Date(dateStr + 'T00:00:00'));
  39. });
  40. this.fetchDayCounts(this.displayMonday);
  41. }
  42. // ── Wochen-Navigation ─────────────────────────────────────────────────────
  43. navigateWeek(direction) {
  44. const slideOut = direction > 0 ? 'slide-out-left' : 'slide-out-right';
  45. const slideIn = direction > 0 ? 'slide-in-right' : 'slide-in-left';
  46. this.daysContainer.classList.add(slideOut);
  47. setTimeout(() => {
  48. const newMonday = new Date(this.displayMonday);
  49. newMonday.setDate(newMonday.getDate() + direction * 7);
  50. this.displayMonday = newMonday;
  51. this.renderWeekDays();
  52. this.updateWeekLabel();
  53. this.daysContainer.classList.remove(slideOut);
  54. this.daysContainer.classList.add(slideIn);
  55. requestAnimationFrame(() => requestAnimationFrame(() => {
  56. this.daysContainer.classList.remove(slideIn);
  57. }));
  58. window.history.pushState({}, '', `/week/${this.formatDate(this.displayMonday)}`);
  59. this.fetchDayCounts(this.displayMonday);
  60. }, FADE_MS);
  61. }
  62. renderWeekDays() {
  63. this.daysContainer.innerHTML = '';
  64. for (let i = 0; i < 7; i++) {
  65. const d = new Date(this.displayMonday);
  66. d.setDate(d.getDate() + i);
  67. const isSelected = this.isSameDay(d, this.selectedDate);
  68. const isToday = this.isSameDay(d, this.today);
  69. const dateStr = this.formatDate(d);
  70. const dayData = this.dayCounts[dateStr];
  71. const hasEntries = dayData && dayData.count > 0;
  72. const dayNum = String(d.getDate()).padStart(2, '0');
  73. const monthShort = this.monthsShort[d.getMonth()] ?? '';
  74. const a = document.createElement('a');
  75. a.href = `/week/${dateStr}`;
  76. a.className = 'week-nav__day'
  77. + (isSelected ? ' week-nav__day--active' : '')
  78. + (isToday ? ' week-nav__day--today' : '');
  79. a.dataset.date = dateStr;
  80. if (hasEntries) a.title = this.formatMinutes(dayData.minutes);
  81. a.innerHTML = `
  82. <span class="week-nav__day-name">${esc(this.weekdaysShort[i] ?? '')}</span>
  83. <span class="week-nav__day-date">${dayNum}. ${esc(monthShort)}</span>
  84. ${hasEntries ? '<span class="week-nav__day-dot"></span>' : ''}
  85. `;
  86. this.daysContainer.appendChild(a);
  87. }
  88. this.initDropTargets();
  89. }
  90. goToDate(date) {
  91. this.selectedDate = date;
  92. this.displayMonday = this.getMonday(date);
  93. this.renderWeekDays();
  94. this.updateHeaderMeta();
  95. window.history.pushState({}, '', `/week/${this.formatDate(date)}`);
  96. window.entryManager?.loadEntriesForDate(this.formatDate(date));
  97. if (this.monthOpen) this.closeMonth();
  98. this.fetchDayCounts(this.displayMonday);
  99. }
  100. updateHeaderMeta() {
  101. this.updateDateDisplay();
  102. this.updateWeekLabel();
  103. }
  104. updateDateDisplay() {
  105. const dateEl = document.querySelector('.tt-header__date');
  106. if (!dateEl) return;
  107. const d = this.selectedDate;
  108. const day = d.getDate();
  109. const month = this.months[d.getMonth()] ?? '';
  110. const tomorrow = new Date(this.today); tomorrow.setDate(this.today.getDate() + 1);
  111. const yesterday = new Date(this.today); yesterday.setDate(this.today.getDate() - 1);
  112. const jsDay = d.getDay();
  113. const isoIdx = jsDay === 0 ? 6 : jsDay - 1;
  114. const weekday = this.weekdays[isoIdx] ?? '';
  115. let prefix;
  116. if (this.isSameDay(d, this.today)) prefix = t('today');
  117. else if (this.isSameDay(d, tomorrow)) prefix = t('tomorrow');
  118. else if (this.isSameDay(d, yesterday)) prefix = t('yesterday');
  119. else prefix = weekday;
  120. dateEl.textContent = `${prefix}, ${day}. ${month}`;
  121. document.title = `${prefix}, ${day}. ${month}`;
  122. }
  123. updateWeekLabel() {
  124. const kwEl = document.querySelector('.tt-header__kw');
  125. const weekNum = this.getWeekNumber(this.displayMonday);
  126. if (kwEl) kwEl.textContent = `${t('weekLabel')} ${weekNum}`;
  127. window._updateWeekToggle?.(weekNum);
  128. }
  129. // ── Dot-Indikatoren ───────────────────────────────────────────────────────
  130. async fetchDayCounts(monday) {
  131. const sunday = new Date(monday);
  132. sunday.setDate(sunday.getDate() + 6);
  133. try {
  134. const res = await fetch(`/api/entries/day-counts?from=${this.formatDate(monday)}&to=${this.formatDate(sunday)}`);
  135. if (!res.ok) return;
  136. const counts = await res.json();
  137. Object.assign(this.dayCounts, counts);
  138. this.applyDots();
  139. } catch { /* silent */ }
  140. }
  141. async fetchMonthDayCounts(year, month) {
  142. const from = new Date(year, month, 1);
  143. const to = new Date(year, month + 1, 0);
  144. try {
  145. const res = await fetch(`/api/entries/day-counts?from=${this.formatDate(from)}&to=${this.formatDate(to)}`);
  146. if (!res.ok) return;
  147. const counts = await res.json();
  148. Object.assign(this.dayCounts, counts);
  149. this.applyMonthDots();
  150. } catch { /* silent */ }
  151. }
  152. applyDots() {
  153. this.daysContainer?.querySelectorAll('.week-nav__day').forEach(dayEl => {
  154. const dateStr = dayEl.dataset.date;
  155. const dayData = this.dayCounts[dateStr];
  156. const hasEntries = dayData && dayData.count > 0;
  157. const existing = dayEl.querySelector('.week-nav__day-dot');
  158. if (hasEntries) {
  159. dayEl.title = this.formatMinutes(dayData.minutes);
  160. if (!existing) {
  161. const dot = document.createElement('span');
  162. dot.className = 'week-nav__day-dot';
  163. dayEl.appendChild(dot);
  164. }
  165. } else {
  166. dayEl.removeAttribute('title');
  167. if (existing) existing.remove();
  168. }
  169. });
  170. }
  171. applyMonthDots() {
  172. if (!this.monthEl) return;
  173. this.monthEl.querySelectorAll('.month-day[data-date]').forEach(el => {
  174. const dateStr = el.dataset.date;
  175. const dayData = this.dayCounts[dateStr];
  176. const hasEntries = dayData && dayData.count > 0;
  177. const existing = el.querySelector('.month-day__dot');
  178. if (hasEntries) {
  179. el.title = this.formatMinutes(dayData.minutes);
  180. if (!existing) {
  181. const dot = document.createElement('span');
  182. dot.className = 'month-day__dot';
  183. el.appendChild(dot);
  184. }
  185. } else {
  186. el.removeAttribute('title');
  187. if (existing) existing.remove();
  188. }
  189. });
  190. }
  191. // ── Drop-Targets auf Wochentagen ──────────────────────────────────────────
  192. initDropTargets() {
  193. this.daysContainer?.querySelectorAll('.week-nav__day').forEach(dayEl => {
  194. dayEl.addEventListener('dragover', e => {
  195. e.preventDefault();
  196. e.dataTransfer.dropEffect = 'move';
  197. dayEl.classList.add('week-nav__day--drop-target');
  198. });
  199. dayEl.addEventListener('dragleave', () => {
  200. dayEl.classList.remove('week-nav__day--drop-target');
  201. });
  202. dayEl.addEventListener('drop', e => {
  203. e.preventDefault();
  204. dayEl.classList.remove('week-nav__day--drop-target');
  205. const entryId = e.dataTransfer.getData('text/entry-id');
  206. const targetDate = dayEl.dataset.date;
  207. if (entryId && targetDate) {
  208. window.entryManager?.moveEntry(parseInt(entryId, 10), targetDate);
  209. }
  210. });
  211. });
  212. }
  213. refreshAfterMove() {
  214. this.fetchDayCounts(this.displayMonday);
  215. if (this.monthOpen) {
  216. this.fetchMonthDayCounts(this.monthDate.getFullYear(), this.monthDate.getMonth());
  217. }
  218. }
  219. // ── Monats-Ansicht ────────────────────────────────────────────────────────
  220. toggleMonth() { this.monthOpen ? this.closeMonth() : this.openMonth(); }
  221. openMonth() {
  222. this.monthOpen = true;
  223. this.monthDate = new Date(this.selectedDate);
  224. this.monthEl = document.createElement('div');
  225. this.monthEl.className = 'month-calendar month-calendar--hidden';
  226. const calRect = this.calBtn.getBoundingClientRect();
  227. const headerRect = this.header.getBoundingClientRect();
  228. this.monthEl.style.right = `${Math.max(0, headerRect.right - calRect.right)}px`;
  229. this.header.appendChild(this.monthEl);
  230. this.renderMonthGrid();
  231. requestAnimationFrame(() => requestAnimationFrame(() => {
  232. this.monthEl.classList.remove('month-calendar--hidden');
  233. this.monthEl.classList.add('month-calendar--visible');
  234. }));
  235. this.calBtn.classList.add('week-nav__cal--active');
  236. this.fetchMonthDayCounts(this.monthDate.getFullYear(), this.monthDate.getMonth());
  237. }
  238. closeMonth() {
  239. if (!this.monthEl) return;
  240. this.monthEl.classList.remove('month-calendar--visible');
  241. this.monthEl.classList.add('month-calendar--hidden');
  242. const el = this.monthEl;
  243. setTimeout(() => el.remove(), FADE_MS + 100);
  244. this.monthEl = null;
  245. this.monthOpen = false;
  246. this.calBtn.classList.remove('week-nav__cal--active');
  247. }
  248. renderMonthGrid() {
  249. if (!this.monthEl) return;
  250. const year = this.monthDate.getFullYear();
  251. const month = this.monthDate.getMonth();
  252. const firstDay = new Date(year, month, 1);
  253. let startDow = firstDay.getDay();
  254. startDow = startDow === 0 ? 6 : startDow - 1;
  255. const daysInMonth = new Date(year, month + 1, 0).getDate();
  256. const daysInPrev = new Date(year, month, 0).getDate();
  257. let html = `
  258. <div class="month-calendar__header">
  259. <button class="month-calendar__arrow month-nav-prev" title="${t('prevMonth')}">
  260. <svg viewBox="0 0 8 14" fill="none"><path d="M7 1L1 7L7 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
  261. </button>
  262. <span class="month-calendar__title">${esc(this.months[month] ?? '')} ${year}</span>
  263. <button class="month-calendar__arrow month-nav-next" title="${t('nextMonth')}">
  264. <svg viewBox="0 0 8 14" fill="none"><path d="M1 1L7 7L1 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
  265. </button>
  266. <button class="month-calendar__close week-nav__cal" title="${t('monthView')}">
  267. <svg viewBox="0 0 18 18" fill="none">
  268. <rect x="1" y="3" width="16" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/>
  269. <path d="M1 7h16" stroke="currentColor" stroke-width="1.5"/>
  270. <path d="M5 1v4M13 1v4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
  271. <rect x="4" y="10" width="2" height="2" rx="0.5" fill="currentColor"/>
  272. <rect x="8" y="10" width="2" height="2" rx="0.5" fill="currentColor"/>
  273. <rect x="12" y="10" width="2" height="2" rx="0.5" fill="currentColor"/>
  274. </svg>
  275. </button>
  276. </div>
  277. <div class="month-calendar__grid">
  278. <div class="month-calendar__weekdays">
  279. ${this.weekdaysShort.map(d => `<span>${esc(d)}</span>`).join('')}
  280. </div>
  281. <div class="month-calendar__days">`;
  282. for (let i = startDow - 1; i >= 0; i--)
  283. html += `<span class="month-day month-day--other">${daysInPrev - i}</span>`;
  284. for (let d = 1; d <= daysInMonth; d++) {
  285. const date = new Date(year, month, d);
  286. const isToday = this.isSameDay(date, this.today);
  287. const isActive= this.isSameDay(date, this.selectedDate);
  288. const cls = 'month-day'
  289. + (isToday ? ' month-day--today' : '')
  290. + (isActive ? ' month-day--active' : '');
  291. html += `<span class="${cls}" data-date="${this.formatDate(date)}">${d}</span>`;
  292. }
  293. const totalCells = Math.ceil((startDow + daysInMonth) / 7) * 7;
  294. for (let d = 1; d <= totalCells - startDow - daysInMonth; d++)
  295. html += `<span class="month-day month-day--other">${d}</span>`;
  296. html += `</div></div>`;
  297. this.monthEl.innerHTML = html;
  298. this.monthEl.querySelector('.month-nav-prev')?.addEventListener('click', () => this.navigateMonth(-1));
  299. this.monthEl.querySelector('.month-nav-next')?.addEventListener('click', () => this.navigateMonth(1));
  300. this.monthEl.querySelector('.month-calendar__close')?.addEventListener('click', () => this.closeMonth());
  301. this.monthEl.querySelectorAll('.month-day[data-date]').forEach(el => {
  302. el.addEventListener('click', () => this.goToDate(new Date(el.dataset.date + 'T00:00:00')));
  303. });
  304. this.applyMonthDots();
  305. }
  306. navigateMonth(direction) {
  307. const grid = this.monthEl?.querySelector('.month-calendar__grid');
  308. if (!grid) return;
  309. const slideOut = direction > 0 ? 'slide-out-left' : 'slide-out-right';
  310. grid.classList.add(slideOut);
  311. setTimeout(() => {
  312. this.monthDate.setMonth(this.monthDate.getMonth() + direction);
  313. this.renderMonthGrid();
  314. this.fetchMonthDayCounts(this.monthDate.getFullYear(), this.monthDate.getMonth());
  315. }, 160);
  316. }
  317. // ── Hilfsfunktionen ───────────────────────────────────────────────────────
  318. getMonday(date) {
  319. const d = new Date(date);
  320. const day = d.getDay();
  321. d.setDate(d.getDate() - day + (day === 0 ? -6 : 1));
  322. d.setHours(0, 0, 0, 0);
  323. return d;
  324. }
  325. isSameDay(a, b) {
  326. return a.getFullYear() === b.getFullYear()
  327. && a.getMonth() === b.getMonth()
  328. && a.getDate() === b.getDate();
  329. }
  330. formatDate(date) {
  331. const y = date.getFullYear();
  332. const m = String(date.getMonth() + 1).padStart(2, '0');
  333. const d = String(date.getDate()).padStart(2, '0');
  334. return `${y}-${m}-${d}`;
  335. }
  336. formatMinutes(minutes) {
  337. return `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}`;
  338. }
  339. getWeekNumber(date) {
  340. const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
  341. d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
  342. const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
  343. return Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
  344. }
  345. }
  346. let weekCalendar = null;
  347. document.addEventListener('DOMContentLoaded', () => {
  348. weekCalendar = new WeekCalendar();
  349. window.weekCalendar = weekCalendar;
  350. });