@jjlmoya/utils-travel 1.15.0 → 1.16.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.
Files changed (36) hide show
  1. package/package.json +2 -2
  2. package/src/entries.ts +4 -1
  3. package/src/index.ts +1 -0
  4. package/src/pages/[locale]/[slug].astro +1 -1
  5. package/src/tests/locale_completeness.test.ts +2 -2
  6. package/src/tests/tool_validation.test.ts +2 -2
  7. package/src/tool/schengen-calculator/bibliography.astro +6 -0
  8. package/src/tool/schengen-calculator/bibliography.ts +18 -0
  9. package/src/tool/schengen-calculator/component.astro +199 -0
  10. package/src/tool/schengen-calculator/controller.ts +232 -0
  11. package/src/tool/schengen-calculator/dom-views.ts +122 -0
  12. package/src/tool/schengen-calculator/entry.ts +31 -0
  13. package/src/tool/schengen-calculator/evaluator.ts +74 -0
  14. package/src/tool/schengen-calculator/i18n/de.ts +274 -0
  15. package/src/tool/schengen-calculator/i18n/en.ts +274 -0
  16. package/src/tool/schengen-calculator/i18n/es.ts +274 -0
  17. package/src/tool/schengen-calculator/i18n/fr.ts +274 -0
  18. package/src/tool/schengen-calculator/i18n/id.ts +274 -0
  19. package/src/tool/schengen-calculator/i18n/it.ts +274 -0
  20. package/src/tool/schengen-calculator/i18n/ja.ts +273 -0
  21. package/src/tool/schengen-calculator/i18n/ko.ts +273 -0
  22. package/src/tool/schengen-calculator/i18n/nl.ts +274 -0
  23. package/src/tool/schengen-calculator/i18n/pl.ts +274 -0
  24. package/src/tool/schengen-calculator/i18n/pt.ts +274 -0
  25. package/src/tool/schengen-calculator/i18n/ru.ts +274 -0
  26. package/src/tool/schengen-calculator/i18n/sv.ts +274 -0
  27. package/src/tool/schengen-calculator/i18n/tr.ts +274 -0
  28. package/src/tool/schengen-calculator/i18n/zh.ts +271 -0
  29. package/src/tool/schengen-calculator/index.ts +10 -0
  30. package/src/tool/schengen-calculator/logic.test.ts +79 -0
  31. package/src/tool/schengen-calculator/logic.ts +164 -0
  32. package/src/tool/schengen-calculator/schengen-calculator.css +565 -0
  33. package/src/tool/schengen-calculator/seo.astro +14 -0
  34. package/src/tool/schengen-calculator/storage.ts +38 -0
  35. package/src/tool/schengen-calculator/ui.ts +38 -0
  36. package/src/tools.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-travel",
3
- "version": "1.15.0",
3
+ "version": "1.16.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -28,7 +28,7 @@
28
28
  "check": "astro check",
29
29
  "type-check": "astro check",
30
30
  "test": "vitest run",
31
- "preversion": "npm run lint && npm run test",
31
+ "preversion": "npm run lint && npm run test && npm run build",
32
32
  "postversion": "git push && git push --tags",
33
33
  "patch": "npm version patch",
34
34
  "minor": "npm version minor",
package/src/entries.ts CHANGED
@@ -6,9 +6,12 @@ export { suitcaseChecklist } from './tool/suitcase-checklist/entry';
6
6
  export type { ChecklistItem, ChecklistCategory, SuitcaseChecklistUI } from './tool/suitcase-checklist/entry';
7
7
  export { tipCalculator } from './tool/tip-calculator/entry';
8
8
  export type { TipCountry, TipCalculatorUI } from './tool/tip-calculator/entry';
9
+ export { schengenCalculator } from './tool/schengen-calculator/entry';
10
+ export type { SchengenCalculatorUI } from './tool/schengen-calculator/entry';
9
11
  export { travelCategory } from './category';
10
12
  import { luggageCalculator } from './tool/luggage-calculator/entry';
11
13
  import { miniAdventures } from './tool/mini-adventures/entry';
12
14
  import { suitcaseChecklist } from './tool/suitcase-checklist/entry';
13
15
  import { tipCalculator } from './tool/tip-calculator/entry';
14
- export const ALL_ENTRIES = [luggageCalculator, miniAdventures, suitcaseChecklist, tipCalculator];
16
+ import { schengenCalculator } from './tool/schengen-calculator/entry';
17
+ export const ALL_ENTRIES = [luggageCalculator, miniAdventures, suitcaseChecklist, tipCalculator, schengenCalculator];
package/src/index.ts CHANGED
@@ -20,3 +20,4 @@ export { luggageCalculator, LUGGAGE_CALCULATOR_TOOL } from './tool/luggage-calcu
20
20
  export { tipCalculator, TIP_CALCULATOR_TOOL } from './tool/tip-calculator';
21
21
  export { suitcaseChecklist, SUITCASE_CHECKLIST_TOOL } from './tool/suitcase-checklist';
22
22
  export { miniAdventures, MINI_ADVENTURES_TOOL } from './tool/mini-adventures';
23
+ export { schengenCalculator, SCHENGEN_CALCULATOR_TOOL } from './tool/schengen-calculator';
@@ -15,7 +15,7 @@ export async function getStaticPaths() {
15
15
  const paths = [];
16
16
 
17
17
  for (const { entry, Component: lazyComp } of ALL_TOOLS) {
18
- const { default: Component } = await lazyComp();
18
+ const { default: Component } = (await lazyComp()) as { default: unknown };
19
19
  const localeEntries = Object.entries(entry.i18n) as [
20
20
  KnownLocale,
21
21
  () => Promise<ToolLocaleContent>,
@@ -23,8 +23,8 @@ describe('Locale Completeness Validation', () => {
23
23
  });
24
24
  });
25
25
 
26
- it('all 4 tools registered', () => {
27
- expect(ALL_TOOLS.length).toBe(4);
26
+ it('all 5 tools registered', () => {
27
+ expect(ALL_TOOLS.length).toBe(5);
28
28
  });
29
29
  });
30
30
 
@@ -4,8 +4,8 @@ import { travelCategory } from '../category';
4
4
 
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
- it('should have 4 tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(4);
7
+ it('should have 5 tools in ALL_TOOLS', () => {
8
+ expect(ALL_TOOLS.length).toBe(5);
9
9
  });
10
10
 
11
11
  it('travelCategory should be defined', () => {
@@ -0,0 +1,6 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { bibliographyEntries } from './bibliography';
4
+ ---
5
+
6
+ <SharedBibliography links={bibliographyEntries} />
@@ -0,0 +1,18 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliographyEntries: BibliographyEntry[] = [
4
+ {
5
+ name: 'European Commission - Visa Policy and Schengen Borders Code',
6
+ url: 'https://home-affairs.ec.europa.eu/policies/schengen/visa-policy_en',
7
+ },
8
+ {
9
+ name: 'EUR-Lex - Regulation (EU) 2016/399 (Schengen Borders Code)',
10
+ url: 'https://eur-lex.europa.eu/eli/reg/2016/399/oj/eng',
11
+ },
12
+ {
13
+ name: 'European External Action Service (EEAS) - Travelling to the Schengen Area',
14
+ url: 'https://www.eeas.europa.eu/eeas/travel-europe-european-entryexit-system-ees_en',
15
+ },
16
+ ];
17
+
18
+ export const bibliography = bibliographyEntries;
@@ -0,0 +1,199 @@
1
+ ---
2
+ import type { SchengenCalculatorUI } from './ui';
3
+ import './schengen-calculator.css';
4
+
5
+ interface Props {
6
+ ui?: SchengenCalculatorUI;
7
+ }
8
+
9
+ const { ui: t } = Astro.props;
10
+ const defaultUI: SchengenCalculatorUI = {
11
+ verdictSafeTitle: 'Safe to Travel (Within Legal Limits)',
12
+ verdictWarningTitle: 'Caution: Approaching 90-Day Limit',
13
+ verdictOverstayTitle: 'Illegal Overstay Detected',
14
+ daysRemainingSub: 'Days Allowed Remaining',
15
+ daysUsedSub: 'Days Used in 180-Day Window',
16
+ maxStaySub: 'Max Continuous Stay from Date',
17
+ fullResetSub: 'Full 90-Day Reset Date',
18
+ plannerHeading: '1. Check Status on Target Date',
19
+ plannerEntryLabel: 'Evaluation Date (Entry / Flight Date)',
20
+ quickDatesLabel: 'Jump to Date',
21
+ presetToday: 'Today',
22
+ presetPlus7: '+1 Week',
23
+ presetPlus14: '+2 Weeks',
24
+ presetPlus30: '+1 Month',
25
+ tripsHeading: '2. Your Schengen Trips (Past & Planned)',
26
+ addTripBtn: '+ Add Trip',
27
+ emptyTripsMsg: 'No trips added yet. Add past or planned trips to calculate your Schengen allowance.',
28
+ colArrival: 'Entry (Arrival)',
29
+ colDeparture: 'Exit (Departure)',
30
+ colDestination: 'Country / Notes',
31
+ colDays: 'Days',
32
+ sampleBtn: 'Load Sample Trips',
33
+ clearBtn: 'Clear All',
34
+ timelineTitle: '180-Day Rolling Window',
35
+ legendInSchengen: 'In Schengen',
36
+ legendOutside: 'Outside',
37
+ legendOverstay: 'Overstay',
38
+ bannerSafe: 'On {date}, you will have used {used} ({rem} available).',
39
+ bannerWarning: 'On {date}, you will have used {used} (only {rem} remaining).',
40
+ bannerOverstay: 'Overstay violation detected starting on {date}. Your itinerary exceeds the legal limit by {days}.',
41
+ unitDays: 'days',
42
+ notesPlaceholder: 'e.g. France, Spain',
43
+ sampleNotes1: 'Italy Roadtrip (20 days)',
44
+ sampleNotes2: 'Germany & Austria (20 days)',
45
+ sampleNotesDefault: 'France & Spain',
46
+ };
47
+
48
+ const ui = { ...defaultUI, ...(t ?? {}) };
49
+ const uiJson = JSON.stringify(ui);
50
+ ---
51
+
52
+ <div class="schengen-card" id="sc-app" data-i18n={uiJson}>
53
+ <div class="sc-hero">
54
+ <div id="sc-status-badge" class="sc-status-pill legal">
55
+ <span id="sc-status-text">{ui.verdictSafeTitle}</span>
56
+ </div>
57
+
58
+ <div class="sc-explanation-banner" id="sc-explanation-banner">
59
+ On <strong id="sc-banner-date">--</strong>, you will have used <strong id="sc-banner-used">0 {ui.unitDays}</strong> (<strong id="sc-banner-rem">90 {ui.unitDays} available</strong>).
60
+ </div>
61
+
62
+ <div class="sc-gauge-wrap">
63
+ <svg class="sc-gauge-svg" viewBox="0 0 120 120">
64
+ <circle class="sc-gauge-track" cx="60" cy="60" r="50" />
65
+ <circle
66
+ class="sc-gauge-bar"
67
+ id="sc-gauge-circle"
68
+ cx="60"
69
+ cy="60"
70
+ r="50"
71
+ stroke-dasharray="314.159"
72
+ stroke-dashoffset="314.159"
73
+ />
74
+ </svg>
75
+ <div class="sc-gauge-content">
76
+ <span class="sc-gauge-num" id="sc-days-remaining">90</span>
77
+ <span class="sc-gauge-caption" id="sc-gauge-caption">{ui.daysRemainingSub}</span>
78
+ </div>
79
+ </div>
80
+
81
+ <div class="sc-metrics-grid">
82
+ <div class="sc-metric-card">
83
+ <span class="sc-metric-title">{ui.daysUsedSub}</span>
84
+ <span class="sc-metric-val" id="sc-days-used">0 / 90</span>
85
+ </div>
86
+ <div class="sc-metric-card">
87
+ <span class="sc-metric-title">{ui.maxStaySub}</span>
88
+ <span class="sc-metric-val" id="sc-max-continuous">90 {ui.unitDays}</span>
89
+ </div>
90
+ <div class="sc-metric-card">
91
+ <span class="sc-metric-title">{ui.fullResetSub}</span>
92
+ <span class="sc-metric-val" id="sc-reset-date">--</span>
93
+ </div>
94
+ </div>
95
+ </div>
96
+
97
+ <div class="sc-divider"></div>
98
+
99
+ <div class="sc-body">
100
+ <div class="sc-section-block">
101
+ <div class="sc-section-header">
102
+ <span class="sc-section-title">{ui.plannerHeading}</span>
103
+ <div class="sc-chip-group">
104
+ <button type="button" class="sc-chip-mini" id="sc-preset-today">{ui.presetToday}</button>
105
+ <button type="button" class="sc-chip-mini" id="sc-preset-plus7">{ui.presetPlus7}</button>
106
+ <button type="button" class="sc-chip-mini" id="sc-preset-plus14">{ui.presetPlus14}</button>
107
+ <button type="button" class="sc-chip-mini" id="sc-preset-plus30">{ui.presetPlus30}</button>
108
+ </div>
109
+ </div>
110
+ <div class="sc-planner-box">
111
+ <div class="sc-field-group">
112
+ <label for="sc-ref-date-input" class="sc-field-label">{ui.plannerEntryLabel}</label>
113
+ <div class="sc-date-wrap">
114
+ <input type="date" id="sc-ref-date-input" class="sc-input-control" />
115
+ <span class="sc-date-icon-btn" aria-hidden="true">
116
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
117
+ <path d="M19 4h-1V2h-2v2H8V2H6v2H5c-1.11 0-1.99.9-1.99 2L3 20c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H5V10h14v10zm0-12H5V6h14v2zM9 14H7v-2h2v2zm4 0h-2v-2h2v2zm4 0h-2v-2h2v2zm-8 4H7v-2h2v2zm4 4h-2v-2h2v2zm4 0h-2v-2h2v2z"/>
118
+ </svg>
119
+ </span>
120
+ </div>
121
+ </div>
122
+ </div>
123
+ </div>
124
+
125
+ <div class="sc-section-block">
126
+ <div class="sc-section-header">
127
+ <span class="sc-section-title">{ui.tripsHeading}</span>
128
+ </div>
129
+ <div class="sc-trips-table-header">
130
+ <span>{ui.colArrival}</span>
131
+ <span>{ui.colDeparture}</span>
132
+ <span>{ui.colDestination}</span>
133
+ <span style="text-align:center;">{ui.colDays}</span>
134
+ <span></span>
135
+ </div>
136
+ <div class="sc-trips-container" id="sc-trips-container"></div>
137
+ <div id="sc-empty-msg" class="sc-empty-box">{ui.emptyTripsMsg}</div>
138
+ </div>
139
+
140
+ <div class="sc-timeline-bar-wrap">
141
+ <div class="sc-ribbon-legend">
142
+ <span>{ui.timelineTitle}</span>
143
+ <div class="sc-legend-items">
144
+ <div class="sc-leg-item">
145
+ <span class="sc-dot spent"></span>
146
+ <span>{ui.legendInSchengen}</span>
147
+ </div>
148
+ <div class="sc-leg-item">
149
+ <span class="sc-dot free"></span>
150
+ <span>{ui.legendOutside}</span>
151
+ </div>
152
+ <div class="sc-leg-item">
153
+ <span class="sc-dot overstay"></span>
154
+ <span>{ui.legendOverstay}</span>
155
+ </div>
156
+ </div>
157
+ </div>
158
+ <div class="sc-ribbon" id="sc-timeline-ribbon"></div>
159
+ </div>
160
+ </div>
161
+
162
+ <div class="sc-footer-actions">
163
+ <div class="sc-chip-group">
164
+ <button type="button" class="sc-btn-ghost" id="sc-sample-btn">{ui.sampleBtn}</button>
165
+ <button type="button" class="sc-btn-ghost" id="sc-clear-btn">{ui.clearBtn}</button>
166
+ </div>
167
+ <button type="button" class="sc-btn-main" id="sc-add-trip-btn">
168
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
169
+ <path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
170
+ </svg>
171
+ <span>{ui.addTripBtn}</span>
172
+ </button>
173
+ </div>
174
+ </div>
175
+
176
+ <script>
177
+ import type { SchengenCalculatorUI } from './ui';
178
+ import { SchengenCalculatorController } from './controller';
179
+
180
+ function initApp() {
181
+ const appEl = document.getElementById('sc-app');
182
+ let i18n = {} as SchengenCalculatorUI;
183
+ if (appEl && appEl.dataset.i18n) {
184
+ try {
185
+ i18n = JSON.parse(appEl.dataset.i18n);
186
+ } catch {
187
+ return;
188
+ }
189
+ }
190
+ const controller = new SchengenCalculatorController(i18n);
191
+ controller.init();
192
+ }
193
+
194
+ if (document.readyState === 'loading') {
195
+ document.addEventListener('DOMContentLoaded', initApp);
196
+ } else {
197
+ initApp();
198
+ }
199
+ </script>
@@ -0,0 +1,232 @@
1
+ import type { SchengenCalculatorUI } from './ui';
2
+ import {
3
+ parseDate,
4
+ formatDate,
5
+ addDays,
6
+ getDaysBetween,
7
+ calculateMaxContinuousStay,
8
+ findEarliestResetDate,
9
+ type TripInterval,
10
+ } from './logic';
11
+ import {
12
+ loadSavedTrips,
13
+ saveTripsToStorage,
14
+ loadSavedReferenceDate,
15
+ saveReferenceDateToStorage,
16
+ } from './storage';
17
+ import { scanOverstayDetails, hasTripOverstay } from './evaluator';
18
+ import {
19
+ renderTimeline,
20
+ updateGaugeDisplay,
21
+ renderOverstayBanner,
22
+ renderNormalBanner,
23
+ buildTripRowHtml,
24
+ } from './dom-views';
25
+
26
+ export class SchengenCalculatorController {
27
+ private trips: TripInterval[] = [];
28
+ private referenceDateStr: string;
29
+ private readonly todayStr: string;
30
+ private readonly i18n: SchengenCalculatorUI;
31
+
32
+ constructor(i18n: SchengenCalculatorUI) {
33
+ this.i18n = i18n;
34
+ this.todayStr = formatDate(new Date());
35
+ this.referenceDateStr = this.todayStr;
36
+ }
37
+
38
+ public init(): void {
39
+ this.trips = loadSavedTrips();
40
+ this.referenceDateStr = loadSavedReferenceDate(this.todayStr);
41
+
42
+ const refInput = document.getElementById('sc-ref-date-input') as HTMLInputElement;
43
+ if (refInput) {
44
+ refInput.value = this.referenceDateStr;
45
+ const onChange = (e: Event) => {
46
+ this.referenceDateStr = (e.target as HTMLInputElement).value || this.todayStr;
47
+ this.recalculate();
48
+ };
49
+ refInput.addEventListener('change', onChange);
50
+ refInput.addEventListener('input', onChange);
51
+ }
52
+
53
+ this.setupPresets(refInput);
54
+ this.setupGlobalActions();
55
+ this.renderTrips();
56
+ }
57
+
58
+ private recalculate(): void {
59
+ const refDate = parseDate(this.referenceDateStr);
60
+ const windowStart = addDays(refDate, -179);
61
+ const scan = scanOverstayDetails(this.trips, refDate);
62
+
63
+ this.updateStatusViews(scan, refDate);
64
+
65
+ const resetEl = document.getElementById('sc-reset-date');
66
+ if (resetEl) resetEl.textContent = findEarliestResetDate(refDate, this.trips);
67
+
68
+ renderTimeline(windowStart, refDate, this.trips, this.i18n);
69
+ saveTripsToStorage(this.trips);
70
+ saveReferenceDateToStorage(this.referenceDateStr);
71
+ }
72
+
73
+ private updateStatusViews(
74
+ scan: ReturnType<typeof scanOverstayDetails>,
75
+ refDate: Date
76
+ ): void {
77
+ const maxCont = document.getElementById('sc-max-continuous');
78
+ if (scan.firstOverstayDate) {
79
+ const cap = `${this.i18n.verdictOverstayTitle} (+${scan.maxOverstayDays} ${this.i18n.unitDays})`;
80
+ updateGaugeDisplay(scan.peakDaysUsed, 0, true, cap);
81
+ renderOverstayBanner(scan.firstOverstayDate, scan.maxOverstayDays, this.i18n);
82
+ if (maxCont) {
83
+ maxCont.textContent = `0 ${this.i18n.unitDays}`;
84
+ maxCont.className = 'sc-metric-val is-overstay';
85
+ }
86
+ } else {
87
+ const remaining = Math.max(0, 90 - scan.daysUsedAtRef);
88
+ updateGaugeDisplay(scan.daysUsedAtRef, remaining, false, this.i18n.daysRemainingSub);
89
+ renderNormalBanner(this.referenceDateStr, scan.daysUsedAtRef, this.i18n);
90
+ if (maxCont) {
91
+ const cont = calculateMaxContinuousStay(refDate, this.referenceDateStr, this.trips);
92
+ maxCont.textContent = `${cont} ${this.i18n.unitDays}`;
93
+ maxCont.className = 'sc-metric-val';
94
+ }
95
+ }
96
+ }
97
+
98
+ private renderTrips(): void {
99
+ const container = document.getElementById('sc-trips-container');
100
+ const emptyMsg = document.getElementById('sc-empty-msg');
101
+ if (!container || !emptyMsg) return;
102
+
103
+ container.innerHTML = '';
104
+ if (this.trips.length === 0) {
105
+ emptyMsg.style.display = 'block';
106
+ this.recalculate();
107
+ return;
108
+ }
109
+
110
+ emptyMsg.style.display = 'none';
111
+ this.trips.forEach((trip) => {
112
+ container.appendChild(this.createTripRow(trip));
113
+ });
114
+ this.recalculate();
115
+ }
116
+
117
+ private createTripRow(trip: TripInterval): HTMLElement {
118
+ const row = document.createElement('div');
119
+ row.className = 'sc-trip-item';
120
+ const s = trip.startDate ? parseDate(trip.startDate) : null;
121
+ const e = trip.endDate ? parseDate(trip.endDate) : null;
122
+ const duration = s && e && s <= e ? getDaysBetween(s, e) : 0;
123
+ const isOverstay = hasTripOverstay(trip, this.trips);
124
+
125
+ if (isOverstay) row.classList.add('is-overstay');
126
+
127
+ row.innerHTML = buildTripRowHtml(trip, duration, isOverstay, this.i18n);
128
+ this.bindRowEvents(row, trip);
129
+ return row;
130
+ }
131
+
132
+ private bindRowEvents(row: HTMLElement, trip: TripInterval): void {
133
+ const updateHandler = (ev: Event) => {
134
+ const target = ev.target as HTMLInputElement;
135
+ const field = target.dataset.field as keyof TripInterval;
136
+ if (field) {
137
+ trip[field] = target.value;
138
+ saveTripsToStorage(this.trips);
139
+ this.recalculate();
140
+ this.updateBadge(row, trip);
141
+ }
142
+ };
143
+
144
+ row.querySelectorAll('input').forEach((input) => {
145
+ input.addEventListener('change', updateHandler);
146
+ input.addEventListener('input', updateHandler);
147
+ });
148
+
149
+ const removeBtn = row.querySelector('[data-action="remove"]');
150
+ if (removeBtn) {
151
+ removeBtn.addEventListener('click', () => {
152
+ this.trips = this.trips.filter((t) => t.id !== trip.id);
153
+ saveTripsToStorage(this.trips);
154
+ this.renderTrips();
155
+ });
156
+ }
157
+ }
158
+
159
+ private updateBadge(row: HTMLElement, trip: TripInterval): void {
160
+ const s = trip.startDate ? parseDate(trip.startDate) : null;
161
+ const e = trip.endDate ? parseDate(trip.endDate) : null;
162
+ const dur = s && e && s <= e ? getDaysBetween(s, e) : 0;
163
+ const badge = row.querySelector('.sc-trip-badge');
164
+ if (badge) badge.textContent = dur > 0 ? `${dur}d` : '--';
165
+ }
166
+
167
+ private setupPresets(refInput: HTMLInputElement | null): void {
168
+ const presets = [
169
+ { id: 'sc-preset-today', days: 0 },
170
+ { id: 'sc-preset-plus7', days: 7 },
171
+ { id: 'sc-preset-plus14', days: 14 },
172
+ { id: 'sc-preset-plus30', days: 30 },
173
+ ];
174
+ presets.forEach(({ id, days }) => {
175
+ const btn = document.getElementById(id);
176
+ if (!btn) return;
177
+ btn.addEventListener('click', () => {
178
+ this.referenceDateStr = formatDate(addDays(parseDate(this.todayStr), days));
179
+ if (refInput) refInput.value = this.referenceDateStr;
180
+ this.recalculate();
181
+ });
182
+ });
183
+ }
184
+
185
+ private setupGlobalActions(): void {
186
+ const addBtn = document.getElementById('sc-add-trip-btn');
187
+ if (addBtn) {
188
+ addBtn.addEventListener('click', () => {
189
+ const ref = parseDate(this.referenceDateStr);
190
+ this.trips.push({
191
+ id: 'trip_' + Date.now(),
192
+ startDate: formatDate(addDays(ref, -30)),
193
+ endDate: formatDate(addDays(ref, -16)),
194
+ notes: this.i18n.sampleNotesDefault,
195
+ });
196
+ this.renderTrips();
197
+ });
198
+ }
199
+ this.setupSampleAndClearButtons();
200
+ }
201
+
202
+ private setupSampleAndClearButtons(): void {
203
+ const sampleBtn = document.getElementById('sc-sample-btn');
204
+ if (sampleBtn) {
205
+ sampleBtn.addEventListener('click', () => {
206
+ const ref = parseDate(this.referenceDateStr);
207
+ this.trips = [
208
+ {
209
+ id: 'sample_1',
210
+ startDate: formatDate(addDays(ref, -110)),
211
+ endDate: formatDate(addDays(ref, -91)),
212
+ notes: this.i18n.sampleNotes1,
213
+ },
214
+ {
215
+ id: 'sample_2',
216
+ startDate: formatDate(addDays(ref, -50)),
217
+ endDate: formatDate(addDays(ref, -31)),
218
+ notes: this.i18n.sampleNotes2,
219
+ },
220
+ ];
221
+ this.renderTrips();
222
+ });
223
+ }
224
+ const clearBtn = document.getElementById('sc-clear-btn');
225
+ if (clearBtn) {
226
+ clearBtn.addEventListener('click', () => {
227
+ this.trips = [];
228
+ this.renderTrips();
229
+ });
230
+ }
231
+ }
232
+ }
@@ -0,0 +1,122 @@
1
+ import type { SchengenCalculatorUI } from './ui';
2
+ import { formatDate, addDays, isDateInTrips, countDaysSpentInWindow, type TripInterval } from './logic';
3
+
4
+ export function renderTimeline(start: Date, end: Date, trips: TripInterval[], i18n: SchengenCalculatorUI): void {
5
+ const ribbon = document.getElementById('sc-timeline-ribbon');
6
+ if (!ribbon) return;
7
+ ribbon.innerHTML = '';
8
+
9
+ let cur = new Date(start.getTime());
10
+ while (cur <= end) {
11
+ const seg = document.createElement('div');
12
+ seg.className = 'sc-seg';
13
+ const isSpent = isDateInTrips(cur, trips);
14
+ if (isSpent) {
15
+ seg.classList.add('spent');
16
+ const curStart = addDays(cur, -179);
17
+ if (countDaysSpentInWindow(curStart, cur, trips) > 90) {
18
+ seg.classList.add('overstay');
19
+ }
20
+ }
21
+ seg.title = `${formatDate(cur)}: ${isSpent ? i18n.legendInSchengen : i18n.legendOutside}`;
22
+ ribbon.appendChild(seg);
23
+ cur = addDays(cur, 1);
24
+ }
25
+ }
26
+
27
+ export function updateGaugeDisplay(used: number, remaining: number, isOverstay: boolean, captionText: string): void {
28
+ const dRem = document.getElementById('sc-days-remaining');
29
+ const dUsed = document.getElementById('sc-days-used');
30
+ const gauge = document.getElementById('sc-gauge-circle');
31
+ const caption = document.getElementById('sc-gauge-caption');
32
+
33
+ if (dRem) {
34
+ dRem.textContent = String(remaining);
35
+ dRem.className = isOverstay ? 'sc-gauge-num is-overstay' : 'sc-gauge-num';
36
+ }
37
+ if (caption) caption.textContent = captionText;
38
+ if (dUsed) {
39
+ dUsed.textContent = `${used} / 90`;
40
+ dUsed.className = isOverstay ? 'sc-metric-val is-overstay' : 'sc-metric-val';
41
+ }
42
+ if (gauge) {
43
+ const offset = isOverstay ? 0 : 314.159 - Math.min(1, used / 90) * 314.159;
44
+ gauge.style.strokeDashoffset = String(offset);
45
+ gauge.setAttribute('class', getGaugeBarClass(used, isOverstay));
46
+ }
47
+ }
48
+
49
+ function getGaugeBarClass(used: number, isOverstay: boolean): string {
50
+ if (isOverstay) return 'sc-gauge-bar is-overstay';
51
+ if (used >= 75) return 'sc-gauge-bar is-warning';
52
+ return 'sc-gauge-bar';
53
+ }
54
+
55
+ export function renderOverstayBanner(overstayDate: string, overstayDays: number, i18n: SchengenCalculatorUI): void {
56
+ const badge = document.getElementById('sc-status-badge');
57
+ const badgeText = document.getElementById('sc-status-text');
58
+ const banner = document.getElementById('sc-explanation-banner');
59
+ if (!badge || !badgeText || !banner) return;
60
+
61
+ const overstayLabel = `${i18n.verdictOverstayTitle} (+${overstayDays} ${i18n.unitDays})`;
62
+ badge.className = 'sc-status-pill overstay';
63
+ badgeText.textContent = overstayLabel;
64
+ banner.className = 'sc-explanation-banner is-overstay';
65
+ banner.innerHTML = i18n.bannerOverstay
66
+ .replace('{date}', `<strong>${overstayDate}</strong>`)
67
+ .replace('{days}', `<strong>${overstayDays} ${i18n.unitDays}</strong>`);
68
+ }
69
+
70
+ export function renderNormalBanner(refDateStr: string, used: number, i18n: SchengenCalculatorUI): void {
71
+ const badge = document.getElementById('sc-status-badge');
72
+ const badgeText = document.getElementById('sc-status-text');
73
+ const banner = document.getElementById('sc-explanation-banner');
74
+ if (!badge || !badgeText || !banner) return;
75
+
76
+ const rem = Math.max(0, 90 - used);
77
+ const isWarn = used >= 75;
78
+ badge.className = isWarn ? 'sc-status-pill warning' : 'sc-status-pill legal';
79
+ badgeText.textContent = isWarn ? i18n.verdictWarningTitle : i18n.verdictSafeTitle;
80
+ banner.className = 'sc-explanation-banner';
81
+
82
+ const tpl = isWarn ? i18n.bannerWarning : i18n.bannerSafe;
83
+ banner.innerHTML = tpl
84
+ .replace('{date}', `<strong>${refDateStr}</strong>`)
85
+ .replace('{used}', `<strong>${used} ${i18n.unitDays}</strong>`)
86
+ .replace('{rem}', `<strong>${rem} ${i18n.unitDays}</strong>`);
87
+ }
88
+
89
+ function buildDateInputHtml(id: string, val: string, field: string, label: string): string {
90
+ return `
91
+ <div>
92
+ <div class="sc-date-wrap">
93
+ <input type="date" id="${id}" class="sc-input-control" value="${val}" data-field="${field}" aria-label="${label}" />
94
+ <span class="sc-date-icon-btn" aria-hidden="true">
95
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
96
+ <path d="M19 4h-1V2h-2v2H8V2H6v2H5c-1.11 0-1.99.9-1.99 2L3 20c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H5V10h14v10zm0-12H5V6h14v2zM9 14H7v-2h2v2zm4 0h-2v-2h2v2zm4 0h-2v-2h2v2zm-8 4H7v-2h2v2zm4 4h-2v-2h2v2zm4 0h-2v-2h2v2z"/>
97
+ </svg>
98
+ </span>
99
+ </div>
100
+ </div>
101
+ `;
102
+ }
103
+
104
+ export function buildTripRowHtml(trip: TripInterval, duration: number, isOverstay: boolean, i18n: SchengenCalculatorUI): string {
105
+ const badgeClass = isOverstay ? 'sc-trip-badge is-overstay' : 'sc-trip-badge';
106
+ const durLabel = duration > 0 ? `${duration}d` : '--';
107
+ const startHtml = buildDateInputHtml(`sc-start-${trip.id}`, trip.startDate, 'startDate', i18n.colArrival);
108
+ const endHtml = buildDateInputHtml(`sc-end-${trip.id}`, trip.endDate, 'endDate', i18n.colDeparture);
109
+ return `
110
+ ${startHtml}
111
+ ${endHtml}
112
+ <div class="sc-trip-notes-col">
113
+ <input type="text" id="sc-notes-${trip.id}" class="sc-input-control" value="${trip.notes || ''}" placeholder="${i18n.notesPlaceholder}" data-field="notes" aria-label="${i18n.colDestination}" />
114
+ </div>
115
+ <div class="${badgeClass}">${durLabel}</div>
116
+ <button type="button" class="sc-btn-del" data-action="remove" aria-label="Remove trip">
117
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
118
+ <path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
119
+ </svg>
120
+ </button>
121
+ `;
122
+ }