@jjlmoya/utils-chrono 1.4.0 → 1.6.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.
- package/package.json +1 -1
- package/src/category/index.ts +4 -0
- package/src/entries.ts +7 -1
- package/src/index.ts +2 -0
- package/src/tests/locale_completeness.test.ts +1 -1
- package/src/tests/no_en_dash.test.ts +41 -0
- package/src/tests/tool_validation.test.ts +1 -1
- package/src/tool/beat-rate-converter/i18n/en.ts +1 -1
- package/src/tool/demagnetizing-timer/components/TimerPanel.astro +45 -17
- package/src/tool/demagnetizing-timer/i18n/de.ts +2 -2
- package/src/tool/demagnetizing-timer/i18n/en.ts +1 -1
- package/src/tool/demagnetizing-timer/i18n/es.ts +1 -1
- package/src/tool/demagnetizing-timer/i18n/id.ts +1 -1
- package/src/tool/demagnetizing-timer/i18n/it.ts +1 -1
- package/src/tool/demagnetizing-timer/i18n/nl.ts +1 -1
- package/src/tool/demagnetizing-timer/i18n/pl.ts +1 -1
- package/src/tool/demagnetizing-timer/i18n/pt.ts +1 -1
- package/src/tool/demagnetizing-timer/i18n/ru.ts +3 -3
- package/src/tool/demagnetizing-timer/i18n/sv.ts +1 -1
- package/src/tool/demagnetizing-timer/i18n/tr.ts +1 -1
- package/src/tool/lume-color-simulator/i18n/de.ts +13 -13
- package/src/tool/lume-color-simulator/i18n/en.ts +8 -8
- package/src/tool/lume-color-simulator/i18n/es.ts +7 -7
- package/src/tool/lume-color-simulator/i18n/fr.ts +4 -4
- package/src/tool/lume-color-simulator/i18n/id.ts +7 -7
- package/src/tool/lume-color-simulator/i18n/it.ts +7 -7
- package/src/tool/lume-color-simulator/i18n/ko.ts +3 -3
- package/src/tool/lume-color-simulator/i18n/nl.ts +8 -8
- package/src/tool/lume-color-simulator/i18n/pl.ts +13 -13
- package/src/tool/lume-color-simulator/i18n/pt.ts +4 -4
- package/src/tool/lume-color-simulator/i18n/ru.ts +8 -8
- package/src/tool/lume-color-simulator/i18n/sv.ts +16 -16
- package/src/tool/lume-color-simulator/i18n/tr.ts +8 -8
- package/src/tool/lume-color-simulator/i18n/zh.ts +7 -7
- package/src/tool/moon-phase-visualizer/i18n/de.ts +10 -10
- package/src/tool/moon-phase-visualizer/i18n/en.ts +6 -6
- package/src/tool/moon-phase-visualizer/i18n/es.ts +4 -4
- package/src/tool/moon-phase-visualizer/i18n/fr.ts +4 -4
- package/src/tool/moon-phase-visualizer/i18n/id.ts +4 -4
- package/src/tool/moon-phase-visualizer/i18n/it.ts +4 -4
- package/src/tool/moon-phase-visualizer/i18n/ko.ts +2 -2
- package/src/tool/moon-phase-visualizer/i18n/nl.ts +6 -6
- package/src/tool/moon-phase-visualizer/i18n/pl.ts +9 -9
- package/src/tool/moon-phase-visualizer/i18n/pt.ts +4 -4
- package/src/tool/moon-phase-visualizer/i18n/ru.ts +4 -4
- package/src/tool/moon-phase-visualizer/i18n/sv.ts +10 -10
- package/src/tool/moon-phase-visualizer/i18n/tr.ts +6 -6
- package/src/tool/moon-phase-visualizer/i18n/zh.ts +4 -4
- package/src/tool/power-reserve-estimator/i18n/de.ts +2 -2
- package/src/tool/power-reserve-estimator/i18n/es.ts +2 -2
- package/src/tool/power-reserve-estimator/i18n/fr.ts +2 -2
- package/src/tool/power-reserve-estimator/i18n/id.ts +2 -2
- package/src/tool/power-reserve-estimator/i18n/it.ts +2 -2
- package/src/tool/power-reserve-estimator/i18n/nl.ts +2 -2
- package/src/tool/power-reserve-estimator/i18n/pt.ts +2 -2
- package/src/tool/service-interval-tracker/bibliography.astro +16 -0
- package/src/tool/service-interval-tracker/bibliography.ts +12 -0
- package/src/tool/service-interval-tracker/client.ts +233 -0
- package/src/tool/service-interval-tracker/component.astro +50 -0
- package/src/tool/service-interval-tracker/components/AddEditModal.astro +75 -0
- package/src/tool/service-interval-tracker/components/DashboardHeader.astro +14 -0
- package/src/tool/service-interval-tracker/components/EmptyState.astro +26 -0
- package/src/tool/service-interval-tracker/entry.ts +56 -0
- package/src/tool/service-interval-tracker/helpers.ts +82 -0
- package/src/tool/service-interval-tracker/i18n/de.ts +117 -0
- package/src/tool/service-interval-tracker/i18n/en.ts +170 -0
- package/src/tool/service-interval-tracker/i18n/es.ts +117 -0
- package/src/tool/service-interval-tracker/i18n/fr.ts +98 -0
- package/src/tool/service-interval-tracker/i18n/id.ts +89 -0
- package/src/tool/service-interval-tracker/i18n/it.ts +88 -0
- package/src/tool/service-interval-tracker/i18n/ja.ts +88 -0
- package/src/tool/service-interval-tracker/i18n/ko.ts +88 -0
- package/src/tool/service-interval-tracker/i18n/nl.ts +88 -0
- package/src/tool/service-interval-tracker/i18n/pl.ts +88 -0
- package/src/tool/service-interval-tracker/i18n/pt.ts +88 -0
- package/src/tool/service-interval-tracker/i18n/ru.ts +88 -0
- package/src/tool/service-interval-tracker/i18n/sv.ts +88 -0
- package/src/tool/service-interval-tracker/i18n/tr.ts +88 -0
- package/src/tool/service-interval-tracker/i18n/zh.ts +88 -0
- package/src/tool/service-interval-tracker/index.ts +11 -0
- package/src/tool/service-interval-tracker/renderer.ts +91 -0
- package/src/tool/service-interval-tracker/seo.astro +16 -0
- package/src/tool/service-interval-tracker/service-interval-tracker.css +767 -0
- package/src/tool/service-interval-tracker/utils.ts +58 -0
- package/src/tool/strap-taper-calculator/i18n/ru.ts +1 -1
- package/src/tool/tachymeter-calculator/bibliography.astro +16 -0
- package/src/tool/tachymeter-calculator/bibliography.ts +16 -0
- package/src/tool/tachymeter-calculator/client.ts +180 -0
- package/src/tool/tachymeter-calculator/component.astro +15 -0
- package/src/tool/tachymeter-calculator/components/CalculatorPanel.astro +121 -0
- package/src/tool/tachymeter-calculator/entry.ts +43 -0
- package/src/tool/tachymeter-calculator/i18n/de.ts +172 -0
- package/src/tool/tachymeter-calculator/i18n/en.ts +172 -0
- package/src/tool/tachymeter-calculator/i18n/es.ts +172 -0
- package/src/tool/tachymeter-calculator/i18n/fr.ts +172 -0
- package/src/tool/tachymeter-calculator/i18n/id.ts +172 -0
- package/src/tool/tachymeter-calculator/i18n/it.ts +172 -0
- package/src/tool/tachymeter-calculator/i18n/ja.ts +172 -0
- package/src/tool/tachymeter-calculator/i18n/ko.ts +172 -0
- package/src/tool/tachymeter-calculator/i18n/nl.ts +172 -0
- package/src/tool/tachymeter-calculator/i18n/pl.ts +172 -0
- package/src/tool/tachymeter-calculator/i18n/pt.ts +172 -0
- package/src/tool/tachymeter-calculator/i18n/ru.ts +172 -0
- package/src/tool/tachymeter-calculator/i18n/sv.ts +172 -0
- package/src/tool/tachymeter-calculator/i18n/tr.ts +172 -0
- package/src/tool/tachymeter-calculator/i18n/zh.ts +172 -0
- package/src/tool/tachymeter-calculator/index.ts +11 -0
- package/src/tool/tachymeter-calculator/seo.astro +16 -0
- package/src/tool/tachymeter-calculator/tachymeter-calculator.css +492 -0
- package/src/tool/tachymeter-calculator/utils.ts +10 -0
- package/src/tool/watch-accuracy-tracker/i18n/pl.ts +1 -1
- package/src/tool/watch-accuracy-tracker/i18n/ru.ts +4 -4
- package/src/tool/watch-savings-planner/i18n/en.ts +3 -3
- package/src/tool/watch-size-comparator/i18n/de.ts +30 -30
- package/src/tool/watch-size-comparator/i18n/en.ts +20 -20
- package/src/tool/watch-size-comparator/i18n/es.ts +16 -16
- package/src/tool/watch-size-comparator/i18n/fr.ts +18 -18
- package/src/tool/watch-size-comparator/i18n/id.ts +18 -18
- package/src/tool/watch-size-comparator/i18n/it.ts +18 -18
- package/src/tool/watch-size-comparator/i18n/ko.ts +11 -11
- package/src/tool/watch-size-comparator/i18n/nl.ts +20 -20
- package/src/tool/watch-size-comparator/i18n/pl.ts +27 -27
- package/src/tool/watch-size-comparator/i18n/pt.ts +17 -17
- package/src/tool/watch-size-comparator/i18n/ru.ts +18 -18
- package/src/tool/watch-size-comparator/i18n/sv.ts +29 -29
- package/src/tool/watch-size-comparator/i18n/tr.ts +20 -20
- package/src/tool/watch-size-comparator/i18n/zh.ts +17 -17
- package/src/tool/water-resistance-converter/i18n/de.ts +1 -1
- package/src/tool/water-resistance-converter/i18n/en.ts +1 -1
- package/src/tool/water-resistance-converter/i18n/es.ts +1 -1
- package/src/tool/water-resistance-converter/i18n/id.ts +1 -1
- package/src/tool/water-resistance-converter/i18n/ja.ts +1 -1
- package/src/tool/water-resistance-converter/i18n/ko.ts +1 -1
- package/src/tool/water-resistance-converter/i18n/nl.ts +1 -1
- package/src/tool/water-resistance-converter/i18n/pl.ts +1 -1
- package/src/tool/water-resistance-converter/i18n/pt.ts +1 -1
- package/src/tool/water-resistance-converter/i18n/ru.ts +1 -1
- package/src/tool/water-resistance-converter/i18n/sv.ts +1 -1
- package/src/tool/water-resistance-converter/i18n/tr.ts +1 -1
- package/src/tool/water-resistance-converter/i18n/zh.ts +1 -1
- package/src/tool/wrist-presence-calculator/i18n/de.ts +1 -1
- package/src/tool/wrist-presence-calculator/i18n/ru.ts +18 -18
- package/src/tools.ts +4 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { serviceIntervalTracker } from './index';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
locale?: KnownLocale;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { locale = 'en' } = Astro.props as Props;
|
|
11
|
+
const loader = serviceIntervalTracker.i18n[locale] || serviceIntervalTracker.i18n.en;
|
|
12
|
+
const content = await loader?.();
|
|
13
|
+
if (!content) return null;
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
{content && <SharedBibliography links={content.bibliography} />}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { BibliographyEntry } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const bibliography: BibliographyEntry[] = [
|
|
4
|
+
{
|
|
5
|
+
name: 'Watch Service Guide: When to Service Your Watch',
|
|
6
|
+
url: 'https://mccaskillandcompany.com/pages/watch-service-guide-know-when-to-service-your-watch',
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: 'HOW TO DETERMINE WHETHER OR NOT YOUR WATCH NEEDS A SERVICE',
|
|
10
|
+
url: 'https://nobswatchmaker.com/blog/the-definitive-guide-to-getting-your-watch-serviced',
|
|
11
|
+
},
|
|
12
|
+
];
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Watch,
|
|
3
|
+
state,
|
|
4
|
+
} from './helpers';
|
|
5
|
+
import { entryHTML } from './renderer';
|
|
6
|
+
|
|
7
|
+
const UI = JSON.parse((document.querySelector('.svc') as HTMLElement).dataset.ui || '{}');
|
|
8
|
+
|
|
9
|
+
const KEY = 'chrono-service-intervals';
|
|
10
|
+
const intervals: Record<string, number> = { automatic: 4, manual: 4, quartz: 7, kinetic: 5 };
|
|
11
|
+
|
|
12
|
+
const roster = document.getElementById('svc-roster') as HTMLElement;
|
|
13
|
+
const empty = document.getElementById('svc-empty') as HTMLElement;
|
|
14
|
+
const emptyBtn = document.getElementById('svc-empty-btn') as HTMLButtonElement;
|
|
15
|
+
const summary = document.getElementById('svc-summary') as HTMLElement;
|
|
16
|
+
const overlay = document.getElementById('svc-overlay') as HTMLElement;
|
|
17
|
+
const modalTitle = document.getElementById('svc-modal-title') as HTMLElement;
|
|
18
|
+
const modalX = document.getElementById('svc-modal-x') as HTMLButtonElement;
|
|
19
|
+
const modalCancel = document.getElementById('svc-modal-cancel') as HTMLButtonElement;
|
|
20
|
+
const modalSave = document.getElementById('svc-modal-save') as HTMLButtonElement;
|
|
21
|
+
const modalErr = document.getElementById('svc-modal-err') as HTMLElement;
|
|
22
|
+
const addBtn = document.getElementById('svc-add') as HTMLButtonElement;
|
|
23
|
+
const nameIn = document.getElementById('svc-name') as HTMLInputElement;
|
|
24
|
+
const movIn = document.getElementById('svc-movement') as HTMLSelectElement;
|
|
25
|
+
const dateIn = document.getElementById('svc-date') as HTMLInputElement;
|
|
26
|
+
const neverIn = document.getElementById('svc-never') as HTMLInputElement;
|
|
27
|
+
|
|
28
|
+
let ws: Watch[] = [];
|
|
29
|
+
let editingId: string | null = null;
|
|
30
|
+
|
|
31
|
+
function load() {
|
|
32
|
+
try {
|
|
33
|
+
ws = JSON.parse(localStorage.getItem(KEY) || '[]');
|
|
34
|
+
} catch {
|
|
35
|
+
ws = [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function save() {
|
|
40
|
+
localStorage.setItem(KEY, JSON.stringify(ws));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function uid(): string {
|
|
44
|
+
return crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function renderSummary() {
|
|
48
|
+
const total = ws.length;
|
|
49
|
+
const h = ws.filter((w) => state(w) === 'healthy').length;
|
|
50
|
+
const d = ws.filter((w) => state(w) === 'due').length;
|
|
51
|
+
const o = ws.filter((w) => state(w) === 'overdue').length;
|
|
52
|
+
|
|
53
|
+
summary.innerHTML = `
|
|
54
|
+
<div class="svc-stat-card">
|
|
55
|
+
<span class="svc-stat-val">${total}</span>
|
|
56
|
+
<span class="svc-stat-lbl">Collection</span>
|
|
57
|
+
</div>
|
|
58
|
+
<div class="svc-stat-card svc-stat-healthy">
|
|
59
|
+
<span class="svc-stat-val">${h}</span>
|
|
60
|
+
<span class="svc-stat-lbl">${UI.healthy || 'Healthy'}</span>
|
|
61
|
+
</div>
|
|
62
|
+
<div class="svc-stat-card svc-stat-due">
|
|
63
|
+
<span class="svc-stat-val">${d}</span>
|
|
64
|
+
<span class="svc-stat-lbl">${UI.due || 'Due'}</span>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="svc-stat-card svc-stat-overdue">
|
|
67
|
+
<span class="svc-stat-val">${o}</span>
|
|
68
|
+
<span class="svc-stat-lbl">${UI.overdue || 'Overdue'}</span>
|
|
69
|
+
</div>
|
|
70
|
+
`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function openModal(title: string, w?: Watch) {
|
|
74
|
+
modalTitle.textContent = title;
|
|
75
|
+
editingId = w ? w.id : null;
|
|
76
|
+
if (w) {
|
|
77
|
+
nameIn.value = w.name;
|
|
78
|
+
movIn.value = w.movement;
|
|
79
|
+
dateIn.value = w.lastService || '';
|
|
80
|
+
neverIn.checked = !w.lastService;
|
|
81
|
+
} else {
|
|
82
|
+
nameIn.value = '';
|
|
83
|
+
movIn.value = 'automatic';
|
|
84
|
+
dateIn.value = '';
|
|
85
|
+
neverIn.checked = false;
|
|
86
|
+
}
|
|
87
|
+
dateIn.disabled = neverIn.checked;
|
|
88
|
+
modalErr.textContent = '';
|
|
89
|
+
overlay.classList.add('open');
|
|
90
|
+
setTimeout(() => nameIn.focus(), 150);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function closeModal() {
|
|
94
|
+
overlay.classList.remove('open');
|
|
95
|
+
editingId = null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function validate(): string | null {
|
|
99
|
+
if (!nameIn.value.trim()) {
|
|
100
|
+
return (UI.nameLabel || 'Name') + ' is required';
|
|
101
|
+
}
|
|
102
|
+
if (!dateIn.value && !neverIn.checked) {
|
|
103
|
+
return (UI.dateLabel || 'Date') + ' is required';
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
neverIn.addEventListener('change', () => {
|
|
109
|
+
dateIn.disabled = neverIn.checked;
|
|
110
|
+
if (neverIn.checked) {
|
|
111
|
+
dateIn.value = '';
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
overlay.addEventListener('click', (e) => {
|
|
116
|
+
if (e.target === overlay) {
|
|
117
|
+
closeModal();
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
modalX.addEventListener('click', closeModal);
|
|
122
|
+
modalCancel.addEventListener('click', closeModal);
|
|
123
|
+
|
|
124
|
+
document.addEventListener('keydown', (e) => {
|
|
125
|
+
if (e.key === 'Escape' && overlay.classList.contains('open')) {
|
|
126
|
+
closeModal();
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
modalSave.addEventListener('click', () => {
|
|
131
|
+
const err = validate();
|
|
132
|
+
if (err) {
|
|
133
|
+
modalErr.textContent = err;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
modalErr.textContent = '';
|
|
137
|
+
|
|
138
|
+
const name = nameIn.value.trim();
|
|
139
|
+
const movement = movIn.value;
|
|
140
|
+
const interval = intervals[movement] || 4;
|
|
141
|
+
let lastService: string | null = null;
|
|
142
|
+
if (!neverIn.checked && dateIn.value) {
|
|
143
|
+
lastService = dateIn.value;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (editingId) {
|
|
147
|
+
const idx = ws.findIndex((w) => w.id === editingId);
|
|
148
|
+
if (idx >= 0) {
|
|
149
|
+
ws[idx] = { id: editingId, name, movement, lastService, intervalYears: interval };
|
|
150
|
+
}
|
|
151
|
+
} else {
|
|
152
|
+
ws.push({ id: uid(), name, movement, lastService, intervalYears: interval });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
save();
|
|
156
|
+
renderAll();
|
|
157
|
+
closeModal();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
addBtn.addEventListener('click', () => openModal(UI.addWatch || 'Add Watch'));
|
|
161
|
+
emptyBtn.addEventListener('click', () => openModal(UI.addWatch || 'Add Watch'));
|
|
162
|
+
|
|
163
|
+
function handleWatchDelete(id: string) {
|
|
164
|
+
if (confirm(UI.confirmDelete || 'Remove this watch?')) {
|
|
165
|
+
ws = ws.filter((x) => x.id !== id);
|
|
166
|
+
save();
|
|
167
|
+
renderAll();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function handleWatchEdit(id: string) {
|
|
172
|
+
const w = ws.find((x) => x.id === id);
|
|
173
|
+
if (w) {
|
|
174
|
+
openModal(UI.editWatch || 'Edit Watch', w);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function handleCardClick(e: Event) {
|
|
179
|
+
const t = e.target as HTMLElement;
|
|
180
|
+
const del = t.closest('.svc-card-del');
|
|
181
|
+
if (del) {
|
|
182
|
+
e.stopPropagation();
|
|
183
|
+
const id = del.getAttribute('data-id');
|
|
184
|
+
if (id) {
|
|
185
|
+
handleWatchDelete(id);
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const card = t.closest('.svc-card');
|
|
190
|
+
if (card && !card.classList.contains('svc-card-add-placeholder')) {
|
|
191
|
+
const id = card.getAttribute('data-id');
|
|
192
|
+
if (id) {
|
|
193
|
+
handleWatchEdit(id);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
document.addEventListener('click', handleCardClick);
|
|
199
|
+
|
|
200
|
+
function renderAll() {
|
|
201
|
+
renderSummary();
|
|
202
|
+
if (!ws.length) {
|
|
203
|
+
empty.style.display = 'block';
|
|
204
|
+
roster.style.display = 'none';
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
empty.style.display = 'none';
|
|
208
|
+
roster.style.display = 'grid';
|
|
209
|
+
const ord: Record<string, number> = { overdue: 0, due: 1, healthy: 2, unknown: 3 };
|
|
210
|
+
const sorted = [...ws].sort((a, b) => (ord[state(a)] ?? 4) - (ord[state(b)] ?? 4));
|
|
211
|
+
|
|
212
|
+
const cardsHTML = sorted.map((w) => entryHTML(w, UI)).join('');
|
|
213
|
+
const addCardHTML = `
|
|
214
|
+
<div class="svc-card svc-card-add-placeholder" id="svc-card-add-placeholder" tabindex="0" role="button">
|
|
215
|
+
<div class="svc-card-add-inner">
|
|
216
|
+
<div class="svc-card-add-icon">
|
|
217
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
|
|
218
|
+
</div>
|
|
219
|
+
<span class="svc-card-add-text">${UI.addWatch || 'Add Watch'}</span>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
`;
|
|
223
|
+
|
|
224
|
+
roster.innerHTML = cardsHTML + addCardHTML;
|
|
225
|
+
|
|
226
|
+
const addPlaceholder = document.getElementById('svc-card-add-placeholder');
|
|
227
|
+
if (addPlaceholder) {
|
|
228
|
+
addPlaceholder.addEventListener('click', () => openModal(UI.addWatch || 'Add Watch'));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
load();
|
|
233
|
+
renderAll();
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
---
|
|
2
|
+
import DashboardHeader from './components/DashboardHeader.astro';
|
|
3
|
+
import EmptyState from './components/EmptyState.astro';
|
|
4
|
+
import AddEditModal from './components/AddEditModal.astro';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
ui: Record<string, string>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { ui } = Astro.props;
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
<div class="svc" data-ui={JSON.stringify(ui)}>
|
|
14
|
+
<div class="svc-header-row">
|
|
15
|
+
<DashboardHeader
|
|
16
|
+
title={ui.title || "Service Tracker"}
|
|
17
|
+
collectionHealthLabel={ui.collectionHealth || "Collection health"}
|
|
18
|
+
/>
|
|
19
|
+
<button class="svc-add-btn-top" id="svc-add" type="button">
|
|
20
|
+
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
|
|
21
|
+
<span>{ui.addWatch || "Add Watch"}</span>
|
|
22
|
+
</button>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<div class="svc-body" id="svc-body">
|
|
26
|
+
<EmptyState
|
|
27
|
+
emptyTitle={ui.emptyTitle || "Your collection is empty"}
|
|
28
|
+
emptyDesc={ui.emptyDesc || "Track service intervals for your watches and never miss a maintenance again."}
|
|
29
|
+
emptyAction={ui.emptyAction || "Add your first watch"}
|
|
30
|
+
/>
|
|
31
|
+
<div class="svc-roster" id="svc-roster"></div>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<AddEditModal
|
|
35
|
+
addWatch={ui.addWatch || "Add Watch"}
|
|
36
|
+
cancel={ui.cancel || "Cancel"}
|
|
37
|
+
save={ui.save || "Save"}
|
|
38
|
+
nameLabel={ui.nameLabel || "Watch name"}
|
|
39
|
+
namePlaceholder={ui.namePlaceholder || "e.g. Rolex Submariner"}
|
|
40
|
+
movementLabel={ui.movementLabel || "Movement type"}
|
|
41
|
+
movementAuto={ui.movementAuto || "Automatic"}
|
|
42
|
+
movementManual={ui.movementManual || "Manual"}
|
|
43
|
+
movementQuartz={ui.movementQuartz || "Quartz"}
|
|
44
|
+
movementKinetic={ui.movementKinetic || "Kinetic"}
|
|
45
|
+
dateLabel={ui.dateLabel || "Last service date"}
|
|
46
|
+
neverServiced={ui.neverServiced || "New or never serviced"}
|
|
47
|
+
/>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<script src="./client.ts"></script>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
addWatch: string;
|
|
4
|
+
cancel: string;
|
|
5
|
+
save: string;
|
|
6
|
+
nameLabel: string;
|
|
7
|
+
namePlaceholder: string;
|
|
8
|
+
movementLabel: string;
|
|
9
|
+
movementAuto: string;
|
|
10
|
+
movementManual: string;
|
|
11
|
+
movementQuartz: string;
|
|
12
|
+
movementKinetic: string;
|
|
13
|
+
dateLabel: string;
|
|
14
|
+
neverServiced: string;
|
|
15
|
+
}
|
|
16
|
+
const {
|
|
17
|
+
addWatch,
|
|
18
|
+
cancel,
|
|
19
|
+
save,
|
|
20
|
+
nameLabel,
|
|
21
|
+
namePlaceholder,
|
|
22
|
+
movementLabel,
|
|
23
|
+
movementAuto,
|
|
24
|
+
movementManual,
|
|
25
|
+
movementQuartz,
|
|
26
|
+
movementKinetic,
|
|
27
|
+
dateLabel,
|
|
28
|
+
neverServiced,
|
|
29
|
+
} = Astro.props;
|
|
30
|
+
---
|
|
31
|
+
<div class="svc-overlay" id="svc-overlay">
|
|
32
|
+
<div class="svc-modal">
|
|
33
|
+
<div class="svc-modal-top">
|
|
34
|
+
<span class="svc-modal-title" id="svc-modal-title">{addWatch}</span>
|
|
35
|
+
<button class="svc-modal-x" id="svc-modal-x" type="button" aria-label="Close modal">
|
|
36
|
+
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
|
37
|
+
</button>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<div class="svc-modal-body">
|
|
41
|
+
<div class="svc-field">
|
|
42
|
+
<label class="svc-label" for="svc-name">{nameLabel}</label>
|
|
43
|
+
<input class="svc-input" id="svc-name" type="text" maxlength="60" placeholder={namePlaceholder} autocomplete="off" />
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<div class="svc-field">
|
|
47
|
+
<label class="svc-label" for="svc-movement">{movementLabel}</label>
|
|
48
|
+
<div class="svc-select-wrapper">
|
|
49
|
+
<select class="svc-input svc-select" id="svc-movement">
|
|
50
|
+
<option value="automatic" selected>{movementAuto}</option>
|
|
51
|
+
<option value="manual">{movementManual}</option>
|
|
52
|
+
<option value="quartz">{movementQuartz}</option>
|
|
53
|
+
<option value="kinetic">{movementKinetic}</option>
|
|
54
|
+
</select>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<div class="svc-field">
|
|
59
|
+
<label class="svc-label" for="svc-date">{dateLabel}</label>
|
|
60
|
+
<input class="svc-input" id="svc-date" type="date" />
|
|
61
|
+
<label class="svc-check">
|
|
62
|
+
<input type="checkbox" id="svc-never" class="svc-checkbox" />
|
|
63
|
+
<span>{neverServiced}</span>
|
|
64
|
+
</label>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<div class="svc-modal-err" id="svc-modal-err"></div>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div class="svc-modal-acts">
|
|
71
|
+
<button class="svc-btn svc-btn-sec" id="svc-modal-cancel" type="button">{cancel}</button>
|
|
72
|
+
<button class="svc-btn svc-btn-pri" id="svc-modal-save" type="button">{save}</button>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
title: string;
|
|
4
|
+
collectionHealthLabel: string;
|
|
5
|
+
}
|
|
6
|
+
const { title, collectionHealthLabel } = Astro.props;
|
|
7
|
+
---
|
|
8
|
+
<div class="svc-dashboard-header">
|
|
9
|
+
<div class="svc-title-area">
|
|
10
|
+
<h2 class="svc-h">{title}</h2>
|
|
11
|
+
<p class="svc-subtitle">{collectionHealthLabel}</p>
|
|
12
|
+
</div>
|
|
13
|
+
<div class="svc-stats-summary" id="svc-summary"></div>
|
|
14
|
+
</div>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
emptyTitle: string;
|
|
4
|
+
emptyDesc: string;
|
|
5
|
+
emptyAction: string;
|
|
6
|
+
}
|
|
7
|
+
const { emptyTitle, emptyDesc, emptyAction } = Astro.props;
|
|
8
|
+
---
|
|
9
|
+
<div class="svc-empty" id="svc-empty">
|
|
10
|
+
<div class="svc-empty-vis">
|
|
11
|
+
<div class="svc-empty-glow"></div>
|
|
12
|
+
<svg class="svc-empty-svg" viewBox="0 0 100 100" fill="none" stroke="currentColor">
|
|
13
|
+
<circle cx="50" cy="50" r="40" stroke-width="2" stroke-dasharray="6 4" opacity="0.3" />
|
|
14
|
+
<circle cx="50" cy="50" r="30" stroke-width="1.5" opacity="0.15" />
|
|
15
|
+
<path d="M50 20v6M50 74v6M20 50h6M74 50h6" stroke-width="2" stroke-linecap="round" opacity="0.4" />
|
|
16
|
+
<path d="M50 50l12-16M50 50l-8 8" stroke-width="2.5" stroke-linecap="round" />
|
|
17
|
+
<circle cx="50" cy="50" r="3" fill="currentColor" />
|
|
18
|
+
</svg>
|
|
19
|
+
</div>
|
|
20
|
+
<p class="svc-empty-title">{emptyTitle}</p>
|
|
21
|
+
<p class="svc-empty-desc">{emptyDesc}</p>
|
|
22
|
+
<button class="svc-empty-btn" id="svc-empty-btn" type="button">
|
|
23
|
+
<svg viewBox="0 0 14 14" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M7 1v12M1 7h12"/></svg>
|
|
24
|
+
<span>{emptyAction}</span>
|
|
25
|
+
</button>
|
|
26
|
+
</div>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ChronoToolEntry, ToolLocaleContent } from '../../types';
|
|
2
|
+
|
|
3
|
+
export type ServiceIntervalTrackerUI = {
|
|
4
|
+
title: string;
|
|
5
|
+
addWatch: string;
|
|
6
|
+
editWatch: string;
|
|
7
|
+
cancel: string;
|
|
8
|
+
save: string;
|
|
9
|
+
deleteWatch: string;
|
|
10
|
+
confirmDelete: string;
|
|
11
|
+
emptyTitle: string;
|
|
12
|
+
emptyDesc: string;
|
|
13
|
+
emptyAction: string;
|
|
14
|
+
healthy: string;
|
|
15
|
+
due: string;
|
|
16
|
+
overdue: string;
|
|
17
|
+
nameLabel: string;
|
|
18
|
+
namePlaceholder: string;
|
|
19
|
+
movementLabel: string;
|
|
20
|
+
movementAuto: string;
|
|
21
|
+
movementManual: string;
|
|
22
|
+
movementQuartz: string;
|
|
23
|
+
movementKinetic: string;
|
|
24
|
+
dateLabel: string;
|
|
25
|
+
neverServiced: string;
|
|
26
|
+
lastServiceLabel: string;
|
|
27
|
+
nextServiceLabel: string;
|
|
28
|
+
serviced: string;
|
|
29
|
+
newWatch: string;
|
|
30
|
+
years: string;
|
|
31
|
+
collectionHealth: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type ServiceIntervalTrackerLocaleContent = ToolLocaleContent<ServiceIntervalTrackerUI>;
|
|
35
|
+
|
|
36
|
+
export const serviceIntervalTracker: ChronoToolEntry<ServiceIntervalTrackerUI> = {
|
|
37
|
+
id: 'service-interval-tracker',
|
|
38
|
+
icons: { bg: 'mdi:wrench-outline', fg: 'mdi:calendar-clock' },
|
|
39
|
+
i18n: {
|
|
40
|
+
de: () => import('./i18n/de').then((m) => m.content),
|
|
41
|
+
en: () => import('./i18n/en').then((m) => m.content),
|
|
42
|
+
es: () => import('./i18n/es').then((m) => m.content),
|
|
43
|
+
fr: () => import('./i18n/fr').then((m) => m.content),
|
|
44
|
+
id: () => import('./i18n/id').then((m) => m.content),
|
|
45
|
+
it: () => import('./i18n/it').then((m) => m.content),
|
|
46
|
+
ja: () => import('./i18n/ja').then((m) => m.content),
|
|
47
|
+
ko: () => import('./i18n/ko').then((m) => m.content),
|
|
48
|
+
nl: () => import('./i18n/nl').then((m) => m.content),
|
|
49
|
+
pl: () => import('./i18n/pl').then((m) => m.content),
|
|
50
|
+
pt: () => import('./i18n/pt').then((m) => m.content),
|
|
51
|
+
ru: () => import('./i18n/ru').then((m) => m.content),
|
|
52
|
+
sv: () => import('./i18n/sv').then((m) => m.content),
|
|
53
|
+
tr: () => import('./i18n/tr').then((m) => m.content),
|
|
54
|
+
zh: () => import('./i18n/zh').then((m) => m.content),
|
|
55
|
+
},
|
|
56
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export interface Watch {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
movement: string;
|
|
5
|
+
lastService: string | null;
|
|
6
|
+
intervalYears: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const MS_YEAR = 31536000000;
|
|
10
|
+
const MS_DAY = 86400000;
|
|
11
|
+
const SIX_MO = 180 * MS_DAY;
|
|
12
|
+
|
|
13
|
+
export function state(w: Watch): string {
|
|
14
|
+
if (!w.lastService) {
|
|
15
|
+
return 'unknown';
|
|
16
|
+
}
|
|
17
|
+
const n = new Date(w.lastService).getTime() + w.intervalYears * MS_YEAR;
|
|
18
|
+
if (n < Date.now()) {
|
|
19
|
+
return 'overdue';
|
|
20
|
+
}
|
|
21
|
+
if (n - Date.now() < SIX_MO) {
|
|
22
|
+
return 'due';
|
|
23
|
+
}
|
|
24
|
+
return 'healthy';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function fmtFull(iso: string): string {
|
|
28
|
+
return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'long' });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function fmtShort(iso: string): string {
|
|
32
|
+
return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short' });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function nextDate(w: Watch): Date | null {
|
|
36
|
+
if (!w.lastService) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
return new Date(new Date(w.lastService).getTime() + w.intervalYears * MS_YEAR);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function nextLabel(w: Watch): { text: string; cls: string } | null {
|
|
43
|
+
const nd = nextDate(w);
|
|
44
|
+
if (!nd) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const left = Math.round((nd.getTime() - Date.now()) / MS_DAY);
|
|
48
|
+
const s = state(w);
|
|
49
|
+
if (s === 'overdue') {
|
|
50
|
+
return { text: `Overdue by ${Math.abs(left).toLocaleString()}d`, cls: 's-o' };
|
|
51
|
+
}
|
|
52
|
+
if (s === 'due') {
|
|
53
|
+
return { text: `${left.toLocaleString()}d left`, cls: 's-d' };
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function pct(w: Watch): number {
|
|
59
|
+
if (!w.lastService) {
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
return Math.min(100, Math.round((Date.now() - new Date(w.lastService).getTime()) / (w.intervalYears * MS_YEAR) * 100));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function esc(s: string): string {
|
|
66
|
+
const d = document.createElement('div');
|
|
67
|
+
d.textContent = s;
|
|
68
|
+
return d.innerHTML;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function getMovementIcon(mov: string): string {
|
|
72
|
+
if (mov === 'automatic') {
|
|
73
|
+
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a10 10 0 0 1 10 10"/><path d="M12 12L2.5 7.5"/><circle cx="12" cy="12" r="2"/></svg>`;
|
|
74
|
+
}
|
|
75
|
+
if (mov === 'manual') {
|
|
76
|
+
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M10 9v10a2 2 0 0 0 2 2h0a2 2 0 0 0 2-2V9"/></svg>`;
|
|
77
|
+
}
|
|
78
|
+
if (mov === 'kinetic') {
|
|
79
|
+
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/><path d="M16 2v4M8 2v4"/></svg>`;
|
|
80
|
+
}
|
|
81
|
+
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="7" width="16" height="10" rx="2" ry="2"/><line x1="22" y1="11" x2="22" y2="13"/><line x1="6" y1="12" x2="10" y2="12"/><line x1="8" y1="10" x2="8" y2="14"/></svg>`;
|
|
82
|
+
}
|