devcoach 0.1.0__py3-none-any.whl

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 (43) hide show
  1. devcoach/SKILL.md +288 -0
  2. devcoach/__init__.py +3 -0
  3. devcoach/cli/__init__.py +0 -0
  4. devcoach/cli/commands.py +793 -0
  5. devcoach/core/__init__.py +0 -0
  6. devcoach/core/coach.py +141 -0
  7. devcoach/core/db.py +768 -0
  8. devcoach/core/detect.py +132 -0
  9. devcoach/core/git.py +97 -0
  10. devcoach/core/models.py +104 -0
  11. devcoach/core/prompts.py +52 -0
  12. devcoach/mcp/__init__.py +0 -0
  13. devcoach/mcp/server.py +545 -0
  14. devcoach/web/__init__.py +0 -0
  15. devcoach/web/app.py +319 -0
  16. devcoach/web/static/favicon.svg +3 -0
  17. devcoach/web/static/relative-time.js +24 -0
  18. devcoach/web/static/style.css +163 -0
  19. devcoach/web/static/vendor/alpinejs.min.js +5 -0
  20. devcoach/web/static/vendor/flatpickr-dark.min.css +795 -0
  21. devcoach/web/static/vendor/flatpickr.min.css +13 -0
  22. devcoach/web/static/vendor/flatpickr.min.js +2 -0
  23. devcoach/web/static/vendor/highlight.min.js +1232 -0
  24. devcoach/web/static/vendor/hljs-dark.min.css +1 -0
  25. devcoach/web/static/vendor/hljs-light.min.css +1 -0
  26. devcoach/web/static/vendor/htmx.min.js +1 -0
  27. devcoach/web/static/vendor/icons/bitbucket.svg +1 -0
  28. devcoach/web/static/vendor/icons/github.svg +1 -0
  29. devcoach/web/static/vendor/icons/gitlab.svg +1 -0
  30. devcoach/web/static/vendor/icons/vscode.svg +41 -0
  31. devcoach/web/static/vendor/marked.min.js +6 -0
  32. devcoach/web/static/vendor/tailwind.js +83 -0
  33. devcoach/web/templates/base.html +80 -0
  34. devcoach/web/templates/lesson_detail.html +215 -0
  35. devcoach/web/templates/lessons.html +546 -0
  36. devcoach/web/templates/profile.html +240 -0
  37. devcoach/web/templates/settings.html +144 -0
  38. devcoach-0.1.0.dist-info/METADATA +443 -0
  39. devcoach-0.1.0.dist-info/RECORD +43 -0
  40. devcoach-0.1.0.dist-info/WHEEL +4 -0
  41. devcoach-0.1.0.dist-info/entry_points.txt +2 -0
  42. devcoach-0.1.0.dist-info/licenses/LICENSE +201 -0
  43. devcoach-0.1.0.dist-info/licenses/NOTICE +20 -0
@@ -0,0 +1,546 @@
1
+ {% extends "base.html" %}
2
+ {% block title %}Lessons — devcoach{% endblock %}
3
+
4
+ {% block head %}
5
+ <link rel="stylesheet" href="/static/vendor/flatpickr.min.css" />
6
+ <style>
7
+ /* Flatpickr dark-mode override */
8
+ .dark .flatpickr-calendar { background:#1f2937; border-color:#374151; }
9
+ .dark .flatpickr-day { color:#e5e7eb; }
10
+ .dark .flatpickr-day:hover { background:#374151; }
11
+ .dark .flatpickr-day.selected,
12
+ .dark .flatpickr-day.startRange,
13
+ .dark .flatpickr-day.endRange,
14
+ .dark .flatpickr-day.inRange { background:#4f46e5; border-color:#4f46e5; color:#fff; }
15
+ .dark .flatpickr-day.today { border-color:#6366f1; }
16
+ .dark .flatpickr-months,
17
+ .dark .flatpickr-month { background:#1f2937; color:#e5e7eb; fill:#e5e7eb; }
18
+ .dark .flatpickr-current-month,
19
+ .dark .flatpickr-monthDropdown-months { color:#e5e7eb; background:#1f2937; }
20
+ .dark .flatpickr-weekday { color:#9ca3af; background:#1f2937; }
21
+ .dark .flatpickr-prev-month svg,
22
+ .dark .flatpickr-next-month svg { fill:#9ca3af; }
23
+ </style>
24
+ {% endblock %}
25
+
26
+ {% block content %}
27
+
28
+ {# ── helpers ── #}
29
+ {% set custom_date = selected_date_from or selected_date_to %}
30
+ {% set any_filter = (selected_period != 'all' and not custom_date) or custom_date or selected_category or selected_level or selected_project or selected_repository or selected_branch or selected_commit or selected_starred or selected_search or selected_feedback %}
31
+
32
+ {# period label shown on the button #}
33
+ {% if custom_date %}
34
+ {% if selected_date_from and selected_date_to %}
35
+ {% set period_label = selected_date_from ~ ' → ' ~ selected_date_to %}
36
+ {% elif selected_date_from %}
37
+ {% set period_label = 'From ' ~ selected_date_from %}
38
+ {% else %}
39
+ {% set period_label = 'Until ' ~ selected_date_to %}
40
+ {% endif %}
41
+ {% else %}
42
+ {% set _pl = {'all':'All time','today':'Today','week':'Last 7 days','month':'Last 30 days','year':'Last year'} %}
43
+ {% set period_label = _pl.get(selected_period, 'All time') %}
44
+ {% endif %}
45
+
46
+ {# feedback label #}
47
+ {% set _fl = {'know':'✓ Known','dont_know':'✗ Don\'t know','none':'— No response'} %}
48
+ {% set feedback_label = _fl.get(selected_feedback, 'Feedback') if selected_feedback else 'Feedback' %}
49
+
50
+ <form id="filter-form" method="get" action="/lessons">
51
+ {# preserve all current filters as hidden inputs — individual controls override them #}
52
+ <input type="hidden" name="period" id="h-period" value="{{ selected_period }}">
53
+ <input type="hidden" name="date_from" id="h-date-from" value="{{ selected_date_from }}">
54
+ <input type="hidden" name="date_to" id="h-date-to" value="{{ selected_date_to }}">
55
+ <input type="hidden" name="feedback" id="h-feedback" value="{{ selected_feedback }}">
56
+ <input type="hidden" name="starred" id="h-starred" value="{{ '1' if selected_starred else '' }}">
57
+ <input type="hidden" name="category" value="{{ selected_category }}">
58
+ <input type="hidden" name="level" id="h-level" value="{{ selected_level }}">
59
+ <input type="hidden" name="project" value="{{ selected_project }}">
60
+ <input type="hidden" name="repository" value="{{ selected_repository }}">
61
+ <input type="hidden" name="branch" value="{{ selected_branch }}">
62
+ <input type="hidden" name="commit" value="{{ selected_commit }}">
63
+
64
+ {# ── Search bar ── #}
65
+ <div class="flex items-center gap-3 mb-4">
66
+ <div class="relative flex-1">
67
+ <span class="absolute inset-y-0 left-3.5 flex items-center text-gray-400 pointer-events-none text-sm">🔍</span>
68
+ <input type="text" name="search" value="{{ selected_search }}"
69
+ placeholder="Search lessons…" autocomplete="off"
70
+ class="w-full pl-9 pr-10 py-3 rounded-xl bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 text-gray-800 dark:text-gray-100 placeholder-gray-400 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent" />
71
+ {% if selected_search %}
72
+ <button type="submit" name="search" value=""
73
+ class="absolute inset-y-0 right-3 flex items-center text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 text-xl leading-none">×</button>
74
+ {% endif %}
75
+ </div>
76
+ <p class="text-sm text-gray-400 dark:text-gray-500 whitespace-nowrap shrink-0">
77
+ {% if total == 0 %}No lessons
78
+ {% elif total_pages == 1 %}{{ total }} lesson{{ 's' if total != 1 }}
79
+ {% else %}{{ (page - 1) * per_page + 1 }}–{{ [page * per_page, total] | min }} of {{ total }}
80
+ {% endif %}
81
+ </p>
82
+ </div>
83
+
84
+ {# ── Filter bar ── #}
85
+ <div class="flex flex-wrap items-center gap-2 mb-3">
86
+
87
+ {# Period dropdown #}
88
+ <div class="relative" x-data="periodPicker()" @keydown.escape="close()">
89
+ <button type="button" @click="toggle()"
90
+ class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium border transition
91
+ {% if custom_date or selected_period != 'all' %}
92
+ bg-indigo-600 text-white border-indigo-600
93
+ {% else %}
94
+ bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-200 hover:border-indigo-400
95
+ {% endif %}">
96
+ <span>📅</span>
97
+ <span x-text="label">{{ period_label }}</span>
98
+ <svg class="w-3 h-3 opacity-60" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7"/></svg>
99
+ </button>
100
+
101
+ {# Dropdown panel #}
102
+ <div x-show="open" x-transition:enter="transition ease-out duration-100"
103
+ x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
104
+ x-transition:leave="transition ease-in duration-75"
105
+ x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
106
+ @click.outside="close()"
107
+ class="absolute left-0 top-full mt-1 z-50 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg w-64 overflow-hidden"
108
+ style="display:none">
109
+
110
+ {# Presets #}
111
+ <div class="p-1">
112
+ {% for val, lbl in [('all','All time'),('today','Today'),('week','Last 7 days'),('month','Last 30 days'),('year','Last year')] %}
113
+ <button type="button" @click="selectPreset('{{ val }}', '{{ lbl }}')"
114
+ class="w-full text-left px-3 py-2 rounded-lg text-sm transition flex items-center justify-between
115
+ {% if selected_period == val and not custom_date %}
116
+ bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 font-medium
117
+ {% else %}
118
+ text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800
119
+ {% endif %}">
120
+ {{ lbl }}
121
+ {% if selected_period == val and not custom_date %}<span class="text-indigo-500">✓</span>{% endif %}
122
+ </button>
123
+ {% endfor %}
124
+ </div>
125
+
126
+ <div class="border-t border-gray-100 dark:border-gray-800 p-1">
127
+ <button type="button" @click="showCustom = !showCustom"
128
+ class="w-full text-left px-3 py-2 rounded-lg text-sm transition flex items-center justify-between
129
+ {% if custom_date %}
130
+ bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 font-medium
131
+ {% else %}
132
+ text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800
133
+ {% endif %}">
134
+ <span>Custom range</span>
135
+ <svg class="w-3 h-3 opacity-60 transition-transform" :class="showCustom ? 'rotate-180' : ''"
136
+ fill="none" viewBox="0 0 24 24" stroke="currentColor">
137
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7"/>
138
+ </svg>
139
+ </button>
140
+
141
+ <div x-show="showCustom" class="px-2 pb-2 pt-1">
142
+ <input type="text" x-ref="fp"
143
+ placeholder="Select date range…"
144
+ class="w-full text-xs bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg px-2 py-1.5 text-gray-700 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 cursor-pointer" />
145
+ </div>
146
+ </div>
147
+ </div>
148
+ </div>
149
+
150
+ {# Feedback dropdown #}
151
+ <div class="relative" x-data="{ open: false }" @click.outside="open = false" @keydown.escape="open = false">
152
+ <button type="button" @click="open = !open"
153
+ class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium border transition
154
+ {% if selected_feedback %}
155
+ bg-indigo-600 text-white border-indigo-600
156
+ {% else %}
157
+ bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-200 hover:border-indigo-400
158
+ {% endif %}">
159
+ {{ feedback_label }}
160
+ <svg class="w-3 h-3 opacity-60" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7"/></svg>
161
+ </button>
162
+ <div x-show="open" x-transition:enter="transition ease-out duration-100" x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
163
+ class="absolute left-0 top-full mt-1 z-50 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg w-44 p-1 overflow-hidden" style="display:none">
164
+ {% for val, lbl in [('','All feedback'),('know','✓ Known'),('dont_know','✗ Don\'t know'),('none','— No response')] %}
165
+ <button type="button"
166
+ onclick="document.getElementById('h-feedback').value='{{ val }}'; document.getElementById('filter-form').submit()"
167
+ class="w-full text-left px-3 py-2 rounded-lg text-sm transition flex items-center justify-between
168
+ {% if selected_feedback == val %}
169
+ bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 font-medium
170
+ {% else %}
171
+ text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800
172
+ {% endif %}">
173
+ {{ lbl }}{% if selected_feedback == val %}<span class="text-indigo-500 ml-auto">✓</span>{% endif %}
174
+ </button>
175
+ {% endfor %}
176
+ </div>
177
+ </div>
178
+
179
+ {# Level dropdown #}
180
+ {% set _lc = {'junior':'🟢','mid':'🟡','senior':'🔴'} %}
181
+ <div class="relative" x-data="{ open: false }" @click.outside="open = false" @keydown.escape="open = false">
182
+ <button type="button" @click="open = !open"
183
+ class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium border transition
184
+ {% if selected_level %}
185
+ bg-indigo-600 text-white border-indigo-600
186
+ {% else %}
187
+ bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-200 hover:border-indigo-400
188
+ {% endif %}">
189
+ {{ (_lc[selected_level] ~ ' ' ~ selected_level) if selected_level else 'Level' }}
190
+ <svg class="w-3 h-3 opacity-60" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7"/></svg>
191
+ </button>
192
+ <div x-show="open" x-transition:enter="transition ease-out duration-100" x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
193
+ class="absolute left-0 top-full mt-1 z-50 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg w-40 p-1 overflow-hidden" style="display:none">
194
+ {% for val, lbl in [('','All levels'),('junior','🟢 Junior'),('mid','🟡 Mid'),('senior','🔴 Senior')] %}
195
+ <button type="button"
196
+ onclick="document.getElementById('h-level').value='{{ val }}'; document.getElementById('filter-form').submit()"
197
+ class="w-full text-left px-3 py-2 rounded-lg text-sm transition flex items-center justify-between
198
+ {% if selected_level == val %}
199
+ bg-indigo-50 dark:bg-indigo-900/30 text-indigo-700 dark:text-indigo-300 font-medium
200
+ {% else %}
201
+ text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800
202
+ {% endif %}">
203
+ {{ lbl }}{% if selected_level == val %}<span class="text-indigo-500 ml-auto">✓</span>{% endif %}
204
+ </button>
205
+ {% endfor %}
206
+ </div>
207
+ </div>
208
+
209
+ {# Starred toggle #}
210
+ <button type="button"
211
+ onclick="var h=document.getElementById('h-starred'); h.value=h.value?'':'1'; document.getElementById('filter-form').submit()"
212
+ class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium border transition
213
+ {% if selected_starred %}
214
+ bg-yellow-400 text-yellow-900 border-yellow-400
215
+ {% else %}
216
+ bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:border-yellow-400 hover:text-yellow-500
217
+ {% endif %}">
218
+ ★ Starred
219
+ </button>
220
+
221
+ {# More filters #}
222
+ {% if all_categories or all_projects or all_repositories or all_branches or all_commits %}
223
+ <div class="relative" x-data="{ open: false }" @click.outside="open = false" @keydown.escape="open = false">
224
+ <button type="button" @click="open = !open"
225
+ class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium border transition
226
+ {% if selected_category or selected_project or selected_repository or selected_branch or selected_commit %}
227
+ bg-indigo-600 text-white border-indigo-600
228
+ {% else %}
229
+ bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-200 hover:border-indigo-400
230
+ {% endif %}">
231
+ Filters
232
+ {% if selected_category or selected_project or selected_repository or selected_branch or selected_commit %}
233
+ <span class="bg-white/20 text-white text-xs rounded-full w-4 h-4 flex items-center justify-center font-bold">
234
+ {{ [selected_category, selected_project, selected_repository, selected_branch, selected_commit] | select | list | length }}
235
+ </span>
236
+ {% endif %}
237
+ <svg class="w-3 h-3 opacity-60" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7"/></svg>
238
+ </button>
239
+
240
+ <div x-show="open" x-transition:enter="transition ease-out duration-100" x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
241
+ class="absolute left-0 top-full mt-1 z-50 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg w-64 p-3 space-y-2.5" style="display:none">
242
+
243
+ {% if all_categories %}
244
+ <div>
245
+ <label class="block text-xs text-gray-400 dark:text-gray-500 mb-1">Category</label>
246
+ <select name="category" onchange="this.form.submit()"
247
+ class="w-full bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-200 rounded-lg px-2.5 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-indigo-500">
248
+ <option value="">All categories</option>
249
+ {% for cat in all_categories %}
250
+ <option value="{{ cat }}" {% if selected_category == cat %}selected{% endif %}>{{ cat }}</option>
251
+ {% endfor %}
252
+ </select>
253
+ </div>
254
+ {% endif %}
255
+
256
+ {% if all_projects %}
257
+ <div>
258
+ <label class="block text-xs text-gray-400 dark:text-gray-500 mb-1">Project</label>
259
+ <select name="project" onchange="this.form.submit()"
260
+ class="w-full bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-200 rounded-lg px-2.5 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-indigo-500">
261
+ <option value="">All projects</option>
262
+ {% for p in all_projects %}
263
+ <option value="{{ p }}" {% if selected_project == p %}selected{% endif %}>{{ p }}</option>
264
+ {% endfor %}
265
+ </select>
266
+ </div>
267
+ {% endif %}
268
+
269
+ {% if all_repositories %}
270
+ <div>
271
+ <label class="block text-xs text-gray-400 dark:text-gray-500 mb-1">Repository</label>
272
+ <select name="repository" onchange="this.form.submit()"
273
+ class="w-full bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-200 rounded-lg px-2.5 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-indigo-500">
274
+ <option value="">All repositories</option>
275
+ {% for r in all_repositories %}
276
+ <option value="{{ r }}" {% if selected_repository == r %}selected{% endif %}>{{ r }}</option>
277
+ {% endfor %}
278
+ </select>
279
+ </div>
280
+ {% endif %}
281
+
282
+ {% if all_branches %}
283
+ <div>
284
+ <label class="block text-xs text-gray-400 dark:text-gray-500 mb-1">Branch</label>
285
+ <input type="text" name="branch" value="{{ selected_branch }}"
286
+ placeholder="e.g. main" list="branch-list" autocomplete="off"
287
+ class="w-full bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-200 rounded-lg px-2.5 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-indigo-500" />
288
+ <datalist id="branch-list">{% for b in all_branches %}<option value="{{ b }}">{% endfor %}</datalist>
289
+ </div>
290
+ {% endif %}
291
+
292
+ {% if all_commits %}
293
+ <div>
294
+ <label class="block text-xs text-gray-400 dark:text-gray-500 mb-1">Commit</label>
295
+ <input type="text" name="commit" value="{{ selected_commit }}"
296
+ placeholder="hash prefix…" list="commit-list" autocomplete="off"
297
+ class="w-full bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-200 rounded-lg px-2.5 py-1.5 text-xs font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500" />
298
+ <datalist id="commit-list">{% for c in all_commits %}<option value="{{ c[:7] }}">{% endfor %}</datalist>
299
+ </div>
300
+ {% endif %}
301
+
302
+ <button type="submit" class="w-full bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg px-3 py-1.5 text-xs font-medium transition">Apply</button>
303
+ </div>
304
+ </div>
305
+ {% endif %}
306
+
307
+ {# Clear all #}
308
+ {% if any_filter %}
309
+ <a href="/lessons" class="ml-auto text-xs text-gray-400 hover:text-gray-700 dark:hover:text-white transition">Clear all</a>
310
+ {% endif %}
311
+ </div>
312
+
313
+ {# ── Active filter chips ── #}
314
+ {% if any_filter %}
315
+ <div class="flex flex-wrap gap-1.5 mb-4">
316
+ {% macro chip(label, clear_url) %}
317
+ <span class="inline-flex items-center gap-1 pl-2.5 pr-1 py-0.5 rounded-full text-xs bg-indigo-50 dark:bg-indigo-900/20 text-indigo-700 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800">
318
+ {{ label }}<a href="{{ clear_url }}" class="ml-0.5 w-4 h-4 flex items-center justify-center rounded-full hover:bg-indigo-200 dark:hover:bg-indigo-700 transition">×</a>
319
+ </span>
320
+ {% endmacro %}
321
+
322
+ {% set base = '?period=' ~ selected_period ~ '&category=' ~ selected_category ~ '&level=' ~ selected_level ~ '&project=' ~ selected_project ~ '&repository=' ~ selected_repository ~ '&branch=' ~ selected_branch ~ '&commit=' ~ selected_commit ~ '&feedback=' ~ selected_feedback ~ '&search=' ~ selected_search ~ '&date_from=' ~ selected_date_from ~ '&date_to=' ~ selected_date_to ~ ('&starred=1' if selected_starred else '') ~ '&sort=' ~ selected_sort ~ '&order=' ~ selected_order %}
323
+
324
+ {% if custom_date %}{{ chip('📅 ' ~ period_label, base | replace('&date_from=' ~ selected_date_from, '&date_from=') | replace('&date_to=' ~ selected_date_to, '&date_to=')) }}{% endif %}
325
+ {% if selected_period != 'all' and not custom_date %}{{ chip('🕐 ' ~ period_label, base | replace('period=' ~ selected_period, 'period=all')) }}{% endif %}
326
+ {% if selected_feedback %}{{ chip(_fl.get(selected_feedback, selected_feedback), base | replace('&feedback=' ~ selected_feedback, '&feedback=')) }}{% endif %}
327
+ {% if selected_starred %}<span class="inline-flex items-center gap-1 pl-2.5 pr-1 py-0.5 rounded-full text-xs bg-yellow-50 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-300 border border-yellow-200 dark:border-yellow-800">★ Starred<a href="{{ base | replace('&starred=1','') }}" class="ml-0.5 w-4 h-4 flex items-center justify-center rounded-full hover:bg-yellow-200 dark:hover:bg-yellow-700 transition">×</a></span>{% endif %}
328
+ {% if selected_search %}{{ chip('🔍 "' ~ selected_search ~ '"', base | replace('&search=' ~ selected_search, '&search=')) }}{% endif %}
329
+ {% if selected_category %}{{ chip(selected_category, base | replace('&category=' ~ selected_category, '&category=')) }}{% endif %}
330
+ {% if selected_level %}{{ chip(_lc.get(selected_level, '') ~ ' ' ~ selected_level, base | replace('&level=' ~ selected_level, '&level=')) }}{% endif %}
331
+ {% if selected_project %}{{ chip('📁 ' ~ selected_project, base | replace('&project=' ~ selected_project, '&project=')) }}{% endif %}
332
+ {% if selected_repository %}{{ chip('⎇ ' ~ selected_repository, base | replace('&repository=' ~ selected_repository, '&repository=')) }}{% endif %}
333
+ {% if selected_branch %}{{ chip('⎇ ' ~ selected_branch, base | replace('&branch=' ~ selected_branch, '&branch=')) }}{% endif %}
334
+ {% if selected_commit %}{{ chip(selected_commit[:7], base | replace('&commit=' ~ selected_commit, '&commit=')) }}{% endif %}
335
+ </div>
336
+ {% endif %}
337
+
338
+ </form>
339
+
340
+ {# ── Table ── #}
341
+ {% if lessons %}
342
+ <div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 shadow-sm overflow-hidden">
343
+ <table class="w-full text-sm">
344
+ <thead>
345
+ {# qs without sort/order/page so sort links preserve all filters #}
346
+ {% set sort_base = '?period=' ~ selected_period ~ '&date_from=' ~ selected_date_from ~ '&date_to=' ~ selected_date_to ~ '&category=' ~ selected_category ~ '&level=' ~ selected_level ~ '&project=' ~ selected_project ~ '&repository=' ~ selected_repository ~ '&branch=' ~ selected_branch ~ '&commit=' ~ selected_commit ~ '&feedback=' ~ selected_feedback ~ '&search=' ~ selected_search ~ ('&starred=1' if selected_starred else '') %}
347
+ {% macro sort_th(label, col, extra_class='') %}
348
+ {% set active = (selected_sort == col) %}
349
+ {% set next_order = 'asc' if (active and selected_order == 'desc') else 'desc' %}
350
+ <th class="px-3 py-3 whitespace-nowrap {{ extra_class }}">
351
+ <a href="{{ sort_base }}&sort={{ col }}&order={{ next_order }}"
352
+ class="inline-flex items-center gap-1 hover:text-gray-700 dark:hover:text-gray-200 transition group/sort">
353
+ {{ label }}
354
+ {% if active %}
355
+ <span class="text-indigo-400">{{ '↑' if selected_order == 'asc' else '↓' }}</span>
356
+ {% else %}
357
+ <span class="text-gray-200 dark:text-gray-700 group-hover/sort:text-gray-400 dark:group-hover/sort:text-gray-500 transition">↕</span>
358
+ {% endif %}
359
+ </a>
360
+ </th>
361
+ {% endmacro %}
362
+ <tr class="bg-gray-50 dark:bg-gray-800/60 border-b border-gray-200 dark:border-gray-800 text-left text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
363
+ <th class="w-8 px-3 py-3"></th>
364
+ {{ sort_th('Date', 'timestamp') }}
365
+ {{ sort_th('Topic', 'topic_id', 'hidden sm:table-cell') }}
366
+ {{ sort_th('Title', 'title') }}
367
+ {{ sort_th('Level', 'level') }}
368
+ <th class="px-3 py-3 hidden lg:table-cell">Categories</th>
369
+ {{ sort_th('Feedback', 'feedback', 'hidden xl:table-cell') }}
370
+ </tr>
371
+ </thead>
372
+ <tbody class="divide-y divide-gray-100 dark:divide-gray-800">
373
+ {% for lesson in lessons %}
374
+ {% set lc = {'junior':'text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20',
375
+ 'mid':'text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20',
376
+ 'senior':'text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-900/20'} %}
377
+ <tr class="hover:bg-gray-50 dark:hover:bg-gray-800/40 transition-colors group cursor-pointer"
378
+ onclick="window.location='/lessons/{{ lesson.id }}'">
379
+
380
+ {# Star #}
381
+ <td class="px-3 py-3" onclick="event.stopPropagation()">
382
+ <form method="post" action="/lessons/{{ lesson.id }}/star">
383
+ <input type="hidden" name="next" value="/lessons{% if request.url.query %}?{{ request.url.query }}{% endif %}" />
384
+ <button type="submit" title="{% if lesson.starred %}Unstar{% else %}Star{% endif %}"
385
+ class="w-6 text-lg text-center leading-none transition
386
+ {% if lesson.starred %}text-yellow-400 hover:text-yellow-300{% else %}text-gray-300 dark:text-gray-600 hover:text-yellow-400{% endif %}">
387
+ {% if lesson.starred %}★{% else %}☆{% endif %}
388
+ </button>
389
+ </form>
390
+ </td>
391
+
392
+ {# Date — relative label + exact tooltip on hover #}
393
+ <td class="px-3 py-3 whitespace-nowrap tabular-nums relative group/date">
394
+ <span class="text-gray-400 dark:text-gray-500 cursor-default"
395
+ data-ts="{{ lesson.timestamp_iso }}">{{ lesson.timestamp_iso[:10] }}</span>
396
+ <div class="absolute z-10 bottom-full left-0 mb-1 px-2 py-1 rounded bg-gray-800 dark:bg-gray-700
397
+ text-white text-xs whitespace-nowrap pointer-events-none
398
+ opacity-0 group-hover/date:opacity-100 transition-opacity duration-150">
399
+ {{ lesson.timestamp_iso[:16].replace('T', ' ') }}
400
+ </div>
401
+ </td>
402
+
403
+ {# Topic #}
404
+ <td class="px-3 py-3 hidden sm:table-cell">
405
+ <span class="text-xs font-mono text-cyan-600 dark:text-cyan-400">{{ lesson.topic_id }}</span>
406
+ </td>
407
+
408
+ {# Title #}
409
+ <td class="px-3 py-3 max-w-xs">
410
+ <a href="/lessons/{{ lesson.id }}"
411
+ class="font-medium text-gray-800 dark:text-gray-100 hover:text-indigo-600 dark:hover:text-indigo-400 transition line-clamp-2">
412
+ {{ lesson.title }}
413
+ </a>
414
+ </td>
415
+
416
+ {# Level badge #}
417
+ <td class="px-3 py-3" onclick="event.stopPropagation()">
418
+ <a href="{{ sort_base }}&level={{ lesson.level }}&sort={{ selected_sort }}&order={{ selected_order }}"
419
+ class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium {{ lc.get(lesson.level, '') }} hover:ring-2 hover:ring-current hover:ring-offset-1 transition-shadow">
420
+ {{ lesson.level }}
421
+ </a>
422
+ </td>
423
+
424
+ {# Categories #}
425
+ <td class="px-3 py-3 hidden lg:table-cell" onclick="event.stopPropagation()">
426
+ <div class="flex flex-wrap gap-1">
427
+ {% for cat in lesson.categories %}
428
+ <a href="{{ sort_base }}&category={{ cat }}&sort={{ selected_sort }}&order={{ selected_order }}"
429
+ class="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-indigo-100 dark:hover:bg-indigo-900/40 hover:text-indigo-700 dark:hover:text-indigo-300 transition-colors">{{ cat }}</a>
430
+ {% endfor %}
431
+ </div>
432
+ </td>
433
+
434
+ {# Feedback #}
435
+ <td class="px-3 py-3 hidden xl:table-cell" onclick="event.stopPropagation()">
436
+ {% if lesson.feedback == 'know' %}
437
+ <span class="text-xs text-emerald-600 dark:text-emerald-400 font-medium">✓ Known</span>
438
+ {% elif lesson.feedback == 'dont_know' %}
439
+ <span class="text-xs text-rose-500 dark:text-rose-400 font-medium">✗ Unknown</span>
440
+ {% endif %}
441
+ </td>
442
+ </tr>
443
+ {% endfor %}
444
+ </tbody>
445
+ </table>
446
+ </div>
447
+
448
+ {# ── Pagination ── #}
449
+ {% if total_pages > 1 %}
450
+ {% set qs_base = '?period=' ~ selected_period ~ '&category=' ~ selected_category ~ '&level=' ~ selected_level ~ '&project=' ~ selected_project ~ '&repository=' ~ selected_repository ~ '&branch=' ~ selected_branch ~ '&commit=' ~ selected_commit ~ '&feedback=' ~ selected_feedback ~ '&search=' ~ selected_search ~ '&date_from=' ~ selected_date_from ~ '&date_to=' ~ selected_date_to ~ ('&starred=1' if selected_starred else '') ~ '&sort=' ~ selected_sort ~ '&order=' ~ selected_order %}
451
+ <div class="flex items-center justify-between mt-4">
452
+ <p class="text-xs text-gray-400 dark:text-gray-500">Page {{ page }} of {{ total_pages }}</p>
453
+ <div class="flex items-center gap-1">
454
+
455
+ {# Prev #}
456
+ {% if page > 1 %}
457
+ <a href="{{ qs_base }}&page={{ page - 1 }}"
458
+ class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-medium bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-300 hover:border-indigo-400 transition">← Prev</a>
459
+ {% else %}
460
+ <span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-medium border border-gray-100 dark:border-gray-800 text-gray-300 dark:text-gray-600 cursor-not-allowed">← Prev</span>
461
+ {% endif %}
462
+
463
+ {# Page numbers — show first, last, and a window around current #}
464
+ {% for p in range(1, total_pages + 1) %}
465
+ {% if p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %}
466
+ {% if loop.changed(p > 1 and p != page - 2 and p != 2) %}
467
+ <span class="text-gray-400 dark:text-gray-600 text-xs px-1">…</span>
468
+ {% endif %}
469
+ {% if p == page %}
470
+ <span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-medium bg-indigo-600 text-white border border-indigo-600">{{ p }}</span>
471
+ {% else %}
472
+ <a href="{{ qs_base }}&page={{ p }}"
473
+ class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-medium bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-300 hover:border-indigo-400 transition">{{ p }}</a>
474
+ {% endif %}
475
+ {% endif %}
476
+ {% endfor %}
477
+
478
+ {# Next #}
479
+ {% if page < total_pages %}
480
+ <a href="{{ qs_base }}&page={{ page + 1 }}"
481
+ class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-medium bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-300 hover:border-indigo-400 transition">Next →</a>
482
+ {% else %}
483
+ <span class="inline-flex items-center px-3 py-1.5 rounded-lg text-xs font-medium border border-gray-100 dark:border-gray-800 text-gray-300 dark:text-gray-600 cursor-not-allowed">Next →</span>
484
+ {% endif %}
485
+
486
+ </div>
487
+ </div>
488
+ {% endif %}
489
+
490
+ {% else %}
491
+ <div class="flex flex-col items-center justify-center py-16 text-center">
492
+ <p class="text-3xl mb-3">📭</p>
493
+ <p class="text-gray-500 dark:text-gray-400 text-sm">No lessons match the current filters.</p>
494
+ {% if any_filter %}<a href="/lessons" class="mt-2 text-indigo-500 hover:text-indigo-400 text-sm transition">Clear all filters</a>{% endif %}
495
+ </div>
496
+ {% endif %}
497
+
498
+ {% endblock %}
499
+
500
+ {% block scripts %}
501
+ <script src="/static/vendor/flatpickr.min.js"></script>
502
+ <script src="/static/relative-time.js"></script>
503
+ <script>
504
+ function periodPicker() {
505
+ return {
506
+ open: false,
507
+ showCustom: {{ 'true' if custom_date else 'false' }},
508
+ label: {{ ('"' ~ period_label ~ '"') | safe }},
509
+ fp: null,
510
+
511
+ init() {
512
+ const self = this;
513
+ this.$watch('showCustom', val => {
514
+ if (val && !this.fp) {
515
+ this.fp = flatpickr(this.$refs.fp, {
516
+ mode: 'range',
517
+ dateFormat: 'Y-m-d',
518
+ inline: false,
519
+ {% if selected_date_from %}defaultDate: ['{{ selected_date_from }}'{% if selected_date_to %}, '{{ selected_date_to }}'{% endif %}],{% endif %}
520
+ onChange(dates) {
521
+ if (dates.length === 2) {
522
+ document.getElementById('h-date-from').value = self.fp.formatDate(dates[0], 'Y-m-d');
523
+ document.getElementById('h-date-to').value = self.fp.formatDate(dates[1], 'Y-m-d');
524
+ document.getElementById('h-period').value = '';
525
+ document.getElementById('filter-form').submit();
526
+ }
527
+ }
528
+ });
529
+ }
530
+ if (val && this.fp) this.fp.open();
531
+ });
532
+ },
533
+
534
+ toggle() { this.open = !this.open; },
535
+ close() { this.open = false; },
536
+
537
+ selectPreset(val, lbl) {
538
+ document.getElementById('h-period').value = val;
539
+ document.getElementById('h-date-from').value = '';
540
+ document.getElementById('h-date-to').value = '';
541
+ document.getElementById('filter-form').submit();
542
+ },
543
+ };
544
+ }
545
+ </script>
546
+ {% endblock %}