super-time-tracker-ui 0.1.2

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 (276) hide show
  1. package/AGENTS.md +5 -0
  2. package/CHANGELOG.md +28 -0
  3. package/CLAUDE.md +1 -0
  4. package/README.md +36 -0
  5. package/app/api/backup/route.ts +39 -0
  6. package/app/api/entry/delete-bulk/route.ts +53 -0
  7. package/app/api/entry/move/route.ts +46 -0
  8. package/app/api/entry/move-bulk/route.ts +62 -0
  9. package/app/api/entry/route.ts +75 -0
  10. package/app/api/in/route.ts +38 -0
  11. package/app/api/note/route.ts +120 -0
  12. package/app/api/out/route.ts +31 -0
  13. package/app/api/sheet/route.ts +68 -0
  14. package/app/api/state/route.ts +16 -0
  15. package/app/api/tags/route.ts +75 -0
  16. package/app/color-palettes.css +260 -0
  17. package/app/favicon.ico +0 -0
  18. package/app/globals.css +140 -0
  19. package/app/layout.tsx +54 -0
  20. package/app/page.tsx +24 -0
  21. package/app/reporting/page.tsx +11 -0
  22. package/app/settings/data/page.tsx +9 -0
  23. package/app/settings/display/page.tsx +8 -0
  24. package/app/settings/page.tsx +12 -0
  25. package/app/settings/tags/page.tsx +13 -0
  26. package/bin/stt-ui.js +63 -0
  27. package/components/active-entry-panel.tsx +199 -0
  28. package/components/backup-restore-setting.tsx +168 -0
  29. package/components/check-in-form-collapsed-setting.tsx +44 -0
  30. package/components/check-in-form-collapsible.tsx +52 -0
  31. package/components/check-in-form.tsx +89 -0
  32. package/components/checkbox.tsx +75 -0
  33. package/components/checkout-button-group.tsx +73 -0
  34. package/components/chevron-icon.tsx +25 -0
  35. package/components/clear-tag-filters-on-sheet-change-setting.tsx +45 -0
  36. package/components/color-palette-setting.tsx +75 -0
  37. package/components/compact-lists-setting.tsx +42 -0
  38. package/components/confirm-before-checkout-setting.tsx +42 -0
  39. package/components/confirm-destructive-actions-setting.tsx +46 -0
  40. package/components/confirm-dialog-provider.tsx +71 -0
  41. package/components/confirm-dialog.tsx +90 -0
  42. package/components/data-settings-view.tsx +47 -0
  43. package/components/default-reporting-range-setting.tsx +56 -0
  44. package/components/default-reporting-sort-setting.tsx +45 -0
  45. package/components/default-sheet-session-setting.tsx +118 -0
  46. package/components/display-settings-view.tsx +75 -0
  47. package/components/duration-format-setting.tsx +40 -0
  48. package/components/entry-actions-menu.tsx +207 -0
  49. package/components/entry-edit-form.tsx +113 -0
  50. package/components/entry-list-bulk-bar.tsx +128 -0
  51. package/components/entry-list-sort-setting.tsx +41 -0
  52. package/components/entry-list.tsx +336 -0
  53. package/components/entry-notes-list.tsx +211 -0
  54. package/components/entry-tag-filter.tsx +99 -0
  55. package/components/format_datetime_hint.ts +8 -0
  56. package/components/format_time.ts +10 -0
  57. package/components/general-settings-view.tsx +40 -0
  58. package/components/hamburger-icon.tsx +21 -0
  59. package/components/note-edit-form.tsx +77 -0
  60. package/components/note-form.tsx +109 -0
  61. package/components/pencil-icon.tsx +21 -0
  62. package/components/reporting-date-range-picker.tsx +121 -0
  63. package/components/reporting-sort-controls.tsx +53 -0
  64. package/components/reporting-view.tsx +340 -0
  65. package/components/setting-radio-group.tsx +79 -0
  66. package/components/settings-nav.tsx +66 -0
  67. package/components/settings-page-layout.tsx +53 -0
  68. package/components/settings-saved-toast.tsx +57 -0
  69. package/components/sheet-actions-menu.tsx +108 -0
  70. package/components/sheet-sidebar.tsx +196 -0
  71. package/components/tag-autocomplete-input.tsx +183 -0
  72. package/components/tag-filter-mode-setting.tsx +47 -0
  73. package/components/tag-management-view.tsx +290 -0
  74. package/components/theme-mode-setting.tsx +44 -0
  75. package/components/theme-mode-system-listener.tsx +43 -0
  76. package/components/theme_switcher.tsx +38 -0
  77. package/components/time-format-setting.tsx +39 -0
  78. package/components/timer-in-title-setting.tsx +38 -0
  79. package/components/timer-show-seconds-setting.tsx +41 -0
  80. package/components/tracker-active-bar.tsx +76 -0
  81. package/components/tracker-app.tsx +338 -0
  82. package/components/tracker-breadcrumb.tsx +56 -0
  83. package/components/tracker-document-title.tsx +67 -0
  84. package/components/tracker-topbar.tsx +63 -0
  85. package/components/trash-icon.tsx +24 -0
  86. package/components/week-starts-on-setting.tsx +39 -0
  87. package/eslint.config.mjs +18 -0
  88. package/lib/add_note_to_entry.ts +65 -0
  89. package/lib/api_error_response.ts +10 -0
  90. package/lib/apply_accent_color.ts +12 -0
  91. package/lib/apply_color_palette.ts +12 -0
  92. package/lib/apply_compact_lists.ts +9 -0
  93. package/lib/apply_tag_autocomplete_selection.ts +26 -0
  94. package/lib/apply_theme.ts +8 -0
  95. package/lib/build_reporting_stats.ts +55 -0
  96. package/lib/build_resume_description.ts +15 -0
  97. package/lib/check_in_entry.ts +81 -0
  98. package/lib/check_out_entry.ts +75 -0
  99. package/lib/collect_known_tags.ts +22 -0
  100. package/lib/collect_tag_stats.ts +27 -0
  101. package/lib/collect_tags_from_entries.ts +35 -0
  102. package/lib/config.ts +9 -0
  103. package/lib/convert_json_db.ts +49 -0
  104. package/lib/delete_entries.ts +62 -0
  105. package/lib/delete_entry.ts +29 -0
  106. package/lib/delete_note_on_entry.ts +42 -0
  107. package/lib/delete_sheet.ts +30 -0
  108. package/lib/delete_tracker_action.ts +22 -0
  109. package/lib/edit_entry.ts +56 -0
  110. package/lib/edit_note_on_entry.ts +49 -0
  111. package/lib/ensure_dir_exists.ts +22 -0
  112. package/lib/entry_matches_tag_filter.ts +26 -0
  113. package/lib/fetch_tracker_state.ts +15 -0
  114. package/lib/filter_entries_by_tags.ts +20 -0
  115. package/lib/filter_known_tags.ts +20 -0
  116. package/lib/find_all_serialized_active_entries.ts +28 -0
  117. package/lib/find_serialized_active_entry.ts +12 -0
  118. package/lib/find_serialized_active_entry_for_sheet.ts +31 -0
  119. package/lib/find_sheet_with_active_entry.ts +16 -0
  120. package/lib/format_display_tag.ts +6 -0
  121. package/lib/format_duration.ts +45 -0
  122. package/lib/gen_db.ts +43 -0
  123. package/lib/get_active_panel_class_name.ts +20 -0
  124. package/lib/get_average_entry_ms.ts +13 -0
  125. package/lib/get_button_class_name.ts +24 -0
  126. package/lib/get_check_out_confirm_dialog.ts +19 -0
  127. package/lib/get_clipped_entry_duration_ms.ts +18 -0
  128. package/lib/get_compact_lists_snapshot.ts +15 -0
  129. package/lib/get_date_range_ms_from_inputs.ts +31 -0
  130. package/lib/get_delete_entries_confirm_dialog.ts +21 -0
  131. package/lib/get_delete_entry_confirm_dialog.ts +19 -0
  132. package/lib/get_delete_note_confirm_dialog.ts +21 -0
  133. package/lib/get_delete_sheet_confirm_dialog.ts +25 -0
  134. package/lib/get_entry_duration_ms.ts +14 -0
  135. package/lib/get_entry_row_key.ts +8 -0
  136. package/lib/get_initial_preferred_sheet_name.ts +34 -0
  137. package/lib/get_initial_reporting_range_inputs.ts +31 -0
  138. package/lib/get_input_class_name.ts +15 -0
  139. package/lib/get_merge_tags_confirm_dialog.ts +25 -0
  140. package/lib/get_period_range_ms.ts +43 -0
  141. package/lib/get_reporting_date_range_shortcut_inputs.ts +84 -0
  142. package/lib/get_reporting_period_totals.ts +39 -0
  143. package/lib/get_reporting_stats.ts +25 -0
  144. package/lib/get_restore_db_confirm_dialog.ts +14 -0
  145. package/lib/get_running_entry_key.ts +8 -0
  146. package/lib/get_serialized_entries_total_ms.ts +10 -0
  147. package/lib/get_sheet.ts +14 -0
  148. package/lib/get_sheet_report_stats.ts +22 -0
  149. package/lib/get_sheet_report_stats_for_range.ts +46 -0
  150. package/lib/get_sheet_tag_filter_snapshot.ts +22 -0
  151. package/lib/get_sheets_duration_in_range.ts +27 -0
  152. package/lib/get_tag_autocomplete_context.ts +32 -0
  153. package/lib/get_theme_snapshot.ts +16 -0
  154. package/lib/get_tracker_state.ts +67 -0
  155. package/lib/has_string_value.ts +6 -0
  156. package/lib/is_entry_in_day.ts +15 -0
  157. package/lib/is_idle_sheet_report.ts +8 -0
  158. package/lib/is_json_time_tracker_db.ts +14 -0
  159. package/lib/merge_tags_across_db.ts +79 -0
  160. package/lib/migrate_json_db.ts +56 -0
  161. package/lib/migrate_json_db_to_version_three.ts +51 -0
  162. package/lib/migrate_json_db_to_version_two.ts +50 -0
  163. package/lib/move_entries_to_sheet.ts +152 -0
  164. package/lib/move_entry_to_sheet.ts +82 -0
  165. package/lib/normalize_stored_tag.ts +16 -0
  166. package/lib/notify_settings_saved.ts +47 -0
  167. package/lib/parse_default_sheet_session_mode.ts +21 -0
  168. package/lib/parse_entry_from_input.ts +23 -0
  169. package/lib/parse_natural_language_date.ts +23 -0
  170. package/lib/parse_reporting_source_sheets.ts +22 -0
  171. package/lib/partition_sheet_report_stats.ts +30 -0
  172. package/lib/patch_tracker_action.ts +22 -0
  173. package/lib/persist_ui_preference.ts +18 -0
  174. package/lib/post_tracker_action.ts +22 -0
  175. package/lib/preferences/accent_color_preference.ts +21 -0
  176. package/lib/preferences/check_in_form_collapsed_preference.ts +20 -0
  177. package/lib/preferences/clear_tag_filters_on_sheet_change_preference.ts +20 -0
  178. package/lib/preferences/color_palette_preference.ts +21 -0
  179. package/lib/preferences/confirm_before_checkout_preference.ts +20 -0
  180. package/lib/preferences/confirm_destructive_actions_preference.ts +20 -0
  181. package/lib/preferences/default_reporting_range_preference.ts +21 -0
  182. package/lib/preferences/default_reporting_sort_preference.ts +24 -0
  183. package/lib/preferences/duration_format_preference.ts +19 -0
  184. package/lib/preferences/entry_list_sort_preference.ts +21 -0
  185. package/lib/preferences/tag_filter_mode_preference.ts +18 -0
  186. package/lib/preferences/theme_mode_preference.ts +18 -0
  187. package/lib/preferences/time_format_preference.ts +18 -0
  188. package/lib/preferences/timer_in_title_preference.ts +18 -0
  189. package/lib/preferences/timer_show_seconds_preference.ts +19 -0
  190. package/lib/preferences/week_starts_on_preference.ts +19 -0
  191. package/lib/prompt_check_out_at.ts +17 -0
  192. package/lib/prompt_entry_note.ts +14 -0
  193. package/lib/prune_sheet_tag_filter.ts +27 -0
  194. package/lib/read_db.ts +49 -0
  195. package/lib/read_db_backup_contents.ts +22 -0
  196. package/lib/read_document_compact_lists.ts +12 -0
  197. package/lib/read_document_theme.ts +14 -0
  198. package/lib/read_sheet_tag_filter.ts +26 -0
  199. package/lib/read_stored_active_sheet.ts +14 -0
  200. package/lib/read_stored_compact_lists.ts +24 -0
  201. package/lib/read_stored_default_sheet_fixed_name.ts +16 -0
  202. package/lib/read_stored_default_sheet_session_mode.ts +18 -0
  203. package/lib/read_stored_sheet_tag_filters.ts +28 -0
  204. package/lib/read_stored_theme.ts +18 -0
  205. package/lib/rename_sheet.ts +39 -0
  206. package/lib/rename_tag_across_db.ts +19 -0
  207. package/lib/resolve_active_sheet_name.ts +36 -0
  208. package/lib/resolve_session_preferred_sheet.ts +37 -0
  209. package/lib/resolve_theme.ts +18 -0
  210. package/lib/resolve_theme_mode_to_theme.ts +19 -0
  211. package/lib/restore_db_from_uploaded_json.ts +24 -0
  212. package/lib/serialize_entry.ts +27 -0
  213. package/lib/serialize_reporting_source_sheets.ts +19 -0
  214. package/lib/serialize_sheet_entries.ts +18 -0
  215. package/lib/set_accent_color.ts +12 -0
  216. package/lib/set_active_sheet.ts +18 -0
  217. package/lib/set_color_palette.ts +12 -0
  218. package/lib/set_compact_lists.ts +12 -0
  219. package/lib/set_default_sheet_fixed_name.ts +8 -0
  220. package/lib/set_default_sheet_session_mode.ts +11 -0
  221. package/lib/set_sheet_tag_filter.ts +13 -0
  222. package/lib/set_theme_mode.ts +19 -0
  223. package/lib/sheet_tag_filter_snapshots.ts +48 -0
  224. package/lib/sort_serialized_entries.ts +35 -0
  225. package/lib/sort_sheet_report_stats.ts +43 -0
  226. package/lib/subscribe_compact_lists.ts +25 -0
  227. package/lib/subscribe_sheet_tag_filters.ts +28 -0
  228. package/lib/subscribe_theme.ts +23 -0
  229. package/lib/sync_active_sheet_preference.ts +19 -0
  230. package/lib/tags_are_equal.ts +12 -0
  231. package/lib/theme_init_script.ts +11 -0
  232. package/lib/toggle_sheet_tag_filter.ts +28 -0
  233. package/lib/toggle_theme.ts +20 -0
  234. package/lib/types/confirm_dialog.ts +9 -0
  235. package/lib/types/data.ts +16 -0
  236. package/lib/types/generic_data.ts +25 -0
  237. package/lib/types/index.ts +2 -0
  238. package/lib/types/reporting.ts +59 -0
  239. package/lib/types/tag_management.ts +7 -0
  240. package/lib/types/theme.ts +3 -0
  241. package/lib/types/tracker_state.ts +39 -0
  242. package/lib/types/ui_preferences.ts +104 -0
  243. package/lib/types/ui_settings.ts +17 -0
  244. package/lib/ui_preference_store.ts +80 -0
  245. package/lib/ui_settings_init_script.ts +33 -0
  246. package/lib/use_check_in_form_collapsed.ts +18 -0
  247. package/lib/use_clear_tag_filters_on_sheet_change.ts +18 -0
  248. package/lib/use_confirm_before_checkout.ts +18 -0
  249. package/lib/use_confirm_destructive_actions.ts +18 -0
  250. package/lib/use_duration_format.ts +17 -0
  251. package/lib/use_entry_list_sort.ts +17 -0
  252. package/lib/use_tag_filter_mode.ts +17 -0
  253. package/lib/use_time_format.ts +17 -0
  254. package/lib/use_timer_in_title.ts +18 -0
  255. package/lib/use_timer_show_seconds.ts +18 -0
  256. package/lib/use_week_starts_on.ts +17 -0
  257. package/lib/validate_entry_times.ts +12 -0
  258. package/lib/week_starts_on_to_index.ts +8 -0
  259. package/lib/write_active_sheet_preference.ts +28 -0
  260. package/lib/write_db.ts +20 -0
  261. package/lib/write_sheet_tag_filter.ts +20 -0
  262. package/lib/write_stored_compact_lists.ts +15 -0
  263. package/lib/write_stored_default_sheet_fixed_name.ts +28 -0
  264. package/lib/write_stored_default_sheet_session_mode.ts +24 -0
  265. package/lib/write_stored_sheet_tag_filters.ts +18 -0
  266. package/lib/write_stored_theme.ts +12 -0
  267. package/next.config.ts +7 -0
  268. package/package.json +96 -0
  269. package/pnpm-workspace.yaml +7 -0
  270. package/postcss.config.mjs +7 -0
  271. package/public/file.svg +1 -0
  272. package/public/globe.svg +1 -0
  273. package/public/next.svg +1 -0
  274. package/public/vercel.svg +1 -0
  275. package/public/window.svg +1 -0
  276. package/tsconfig.json +34 -0
@@ -0,0 +1,290 @@
1
+ 'use client'
2
+
3
+ import { useMemo, useState } from 'react'
4
+
5
+ import { use_confirm_dialog } from '@/components/confirm-dialog-provider'
6
+ import { SettingsPageLayout } from '@/components/settings-page-layout'
7
+ import { TagAutocompleteInput } from '@/components/tag-autocomplete-input'
8
+ import { format_display_tag } from '@/lib/format_display_tag'
9
+ import { get_button_class_name } from '@/lib/get_button_class_name'
10
+ import { get_input_class_name } from '@/lib/get_input_class_name'
11
+ import { get_merge_tags_confirm_dialog } from '@/lib/get_merge_tags_confirm_dialog'
12
+ import { type TagStat } from '@/lib/types/tag_management'
13
+
14
+ interface TagManagementViewProps {
15
+ initial_tags: TagStat[]
16
+ }
17
+
18
+ /**
19
+ * Manages renaming and merging tags across all time entries.
20
+ */
21
+ export function TagManagementView({ initial_tags }: TagManagementViewProps) {
22
+ const { confirm } = use_confirm_dialog()
23
+ const [tags, set_tags] = useState<TagStat[]>(initial_tags)
24
+ const [selected_tags, set_selected_tags] = useState<Set<string>>(() => new Set())
25
+ const [rename_values, set_rename_values] = useState<Record<string, string>>({})
26
+ const [merge_target, set_merge_target] = useState('')
27
+ const [error, set_error] = useState<string | null>(null)
28
+ const [status_message, set_status_message] = useState<string | null>(null)
29
+ const [is_pending, set_is_pending] = useState(false)
30
+
31
+ const known_tag_names = useMemo(() => tags.map((tag) => tag.name), [tags])
32
+
33
+ const selected_tag_stats = tags.filter((tag) => selected_tags.has(tag.name))
34
+ const selected_entry_count = selected_tag_stats.reduce(
35
+ (total, tag) => total + tag.entryCount,
36
+ 0,
37
+ )
38
+
39
+ const toggle_selected = (tag_name: string): void => {
40
+ set_selected_tags((previous) => {
41
+ const next = new Set(previous)
42
+
43
+ if (next.has(tag_name)) {
44
+ next.delete(tag_name)
45
+ } else {
46
+ next.add(tag_name)
47
+ }
48
+
49
+ return next
50
+ })
51
+ }
52
+
53
+ const patch_tags = async (body: Record<string, unknown>): Promise<void> => {
54
+ const response = await fetch('/api/tags', {
55
+ method: 'PATCH',
56
+ headers: { 'Content-Type': 'application/json' },
57
+ body: JSON.stringify(body),
58
+ })
59
+
60
+ if (!response.ok) {
61
+ const payload = (await response.json()) as { error?: string }
62
+ throw new Error(payload.error ?? 'Tag update failed')
63
+ }
64
+
65
+ const result = (await response.json()) as {
66
+ tags: TagStat[]
67
+ entries_updated: number
68
+ }
69
+
70
+ set_tags(result.tags)
71
+ set_selected_tags(new Set())
72
+ set_status_message(
73
+ result.entries_updated === 1
74
+ ? 'Updated 1 entry.'
75
+ : `Updated ${result.entries_updated} entries.`,
76
+ )
77
+ }
78
+
79
+ const handle_rename = async (from_tag: string): Promise<void> => {
80
+ const to_tag = rename_values[from_tag]?.trim() ?? ''
81
+
82
+ if (to_tag.length === 0) {
83
+ set_error('Enter a new tag name.')
84
+ return
85
+ }
86
+
87
+ const source_stat = tags.find((tag) => tag.name === from_tag)
88
+
89
+ if (source_stat === undefined) {
90
+ return
91
+ }
92
+
93
+ const target_stat = tags.find(
94
+ (tag) => format_display_tag(tag.name) === format_display_tag(to_tag),
95
+ )
96
+ const affected_entries =
97
+ target_stat !== undefined && target_stat.name !== from_tag
98
+ ? source_stat.entryCount + target_stat.entryCount
99
+ : source_stat.entryCount
100
+
101
+ const confirmed = await confirm(
102
+ get_merge_tags_confirm_dialog([from_tag], to_tag, affected_entries),
103
+ )
104
+
105
+ if (!confirmed) {
106
+ return
107
+ }
108
+
109
+ set_is_pending(true)
110
+ set_error(null)
111
+ set_status_message(null)
112
+
113
+ try {
114
+ await patch_tags({
115
+ action: 'rename',
116
+ fromTag: from_tag,
117
+ toTag: to_tag,
118
+ })
119
+ set_rename_values((previous) => {
120
+ const next = { ...previous }
121
+ delete next[from_tag]
122
+ return next
123
+ })
124
+ } catch (rename_error: unknown) {
125
+ set_error(
126
+ rename_error instanceof Error ? rename_error.message : String(rename_error),
127
+ )
128
+ } finally {
129
+ set_is_pending(false)
130
+ }
131
+ }
132
+
133
+ const handle_merge = async (): Promise<void> => {
134
+ const source_tags = [...selected_tags]
135
+
136
+ if (source_tags.length < 2) {
137
+ set_error('Select at least two tags to merge.')
138
+ return
139
+ }
140
+
141
+ if (merge_target.trim().length === 0) {
142
+ set_error('Enter a target tag for the merge.')
143
+ return
144
+ }
145
+
146
+ const confirmed = await confirm(
147
+ get_merge_tags_confirm_dialog(
148
+ source_tags,
149
+ merge_target,
150
+ selected_entry_count,
151
+ ),
152
+ )
153
+
154
+ if (!confirmed) {
155
+ return
156
+ }
157
+
158
+ set_is_pending(true)
159
+ set_error(null)
160
+ set_status_message(null)
161
+
162
+ try {
163
+ await patch_tags({
164
+ action: 'merge',
165
+ sourceTags: source_tags,
166
+ targetTag: merge_target,
167
+ })
168
+ set_merge_target('')
169
+ } catch (merge_error: unknown) {
170
+ set_error(
171
+ merge_error instanceof Error ? merge_error.message : String(merge_error),
172
+ )
173
+ } finally {
174
+ set_is_pending(false)
175
+ }
176
+ }
177
+
178
+ return (
179
+ <SettingsPageLayout
180
+ breadcrumb={{
181
+ current: 'Tag management',
182
+ parent: { label: 'Settings', href: '/settings' },
183
+ }}
184
+ title="Tag management"
185
+ description="Rename or merge tags across every entry in your database."
186
+ >
187
+
188
+ <section className="rounded-md border border-panel-border bg-panel p-3.5 shadow-sm">
189
+ <h2 className="m-0 text-[0.95rem] font-semibold">Merge tags</h2>
190
+ <p className="m-0 mt-1 text-[0.8rem] leading-snug text-muted">
191
+ Select two or more tags below, then choose the tag they should become.
192
+ </p>
193
+ <label className="mt-3 flex flex-col gap-1 text-[0.82rem] text-muted">
194
+ Target tag
195
+ <TagAutocompleteInput
196
+ id="merge-target-tag"
197
+ value={merge_target}
198
+ known_tags={known_tag_names}
199
+ placeholder="e.g. @project"
200
+ disabled={is_pending}
201
+ on_change={set_merge_target}
202
+ />
203
+ </label>
204
+ <button
205
+ type="button"
206
+ className={`${get_button_class_name('primary', 'small')} mt-3`}
207
+ disabled={is_pending || selected_tags.size < 2}
208
+ onClick={() => void handle_merge()}
209
+ >
210
+ Merge {selected_tags.size} tags
211
+ </button>
212
+ </section>
213
+
214
+ {tags.length === 0 ? (
215
+ <p className="m-0 text-[0.9rem] text-muted">
216
+ No tags yet. Add @tags when you check in to an entry.
217
+ </p>
218
+ ) : (
219
+ <ul
220
+ className="m-0 flex list-none flex-col gap-2 p-0"
221
+ aria-label="Tags"
222
+ >
223
+ {tags.map((tag) => (
224
+ <li
225
+ key={tag.name}
226
+ className="rounded-md border border-panel-border bg-panel px-3.5 py-2.5 shadow-sm"
227
+ >
228
+ <form
229
+ className="flex flex-wrap items-center gap-x-3 gap-y-2"
230
+ onSubmit={(event) => {
231
+ event.preventDefault()
232
+ void handle_rename(tag.name)
233
+ }}
234
+ >
235
+ <input
236
+ type="checkbox"
237
+ className="shrink-0"
238
+ checked={selected_tags.has(tag.name)}
239
+ disabled={is_pending}
240
+ aria-label={`Select ${format_display_tag(tag.name)}`}
241
+ onChange={() => toggle_selected(tag.name)}
242
+ />
243
+ <div className="flex min-w-0 shrink-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
244
+ <span className="font-semibold leading-tight">
245
+ {format_display_tag(tag.name)}
246
+ </span>
247
+ <span className="text-[0.82rem] text-muted">
248
+ {tag.entryCount}{' '}
249
+ {tag.entryCount === 1 ? 'entry' : 'entries'}
250
+ </span>
251
+ </div>
252
+ <div className="ml-auto flex min-w-[min(100%,14rem)] flex-1 basis-56 items-center justify-end gap-2 sm:max-w-xs">
253
+ <input
254
+ className={get_input_class_name('compact')}
255
+ value={rename_values[tag.name] ?? ''}
256
+ placeholder="Rename to…"
257
+ aria-label={`Rename ${format_display_tag(tag.name)}`}
258
+ disabled={is_pending}
259
+ onChange={(event) =>
260
+ set_rename_values((previous) => ({
261
+ ...previous,
262
+ [tag.name]: event.target.value,
263
+ }))
264
+ }
265
+ />
266
+ <button
267
+ type="submit"
268
+ className={`${get_button_class_name('ghost', 'small')} shrink-0`}
269
+ disabled={
270
+ is_pending || (rename_values[tag.name]?.trim().length ?? 0) === 0
271
+ }
272
+ >
273
+ Rename
274
+ </button>
275
+ </div>
276
+ </form>
277
+ </li>
278
+ ))}
279
+ </ul>
280
+ )}
281
+
282
+ {status_message !== null ? (
283
+ <p className="m-0 text-[0.9rem] text-accent">{status_message}</p>
284
+ ) : null}
285
+ {error !== null ? (
286
+ <p className="m-0 text-[0.9rem] text-danger">{error}</p>
287
+ ) : null}
288
+ </SettingsPageLayout>
289
+ )
290
+ }
@@ -0,0 +1,44 @@
1
+ 'use client'
2
+
3
+ import { useSyncExternalStore } from 'react'
4
+
5
+ import { SettingRadioGroup } from '@/components/setting-radio-group'
6
+ import { theme_mode_preference } from '@/lib/preferences/theme_mode_preference'
7
+ import { notify_settings_saved } from '@/lib/notify_settings_saved'
8
+ import { set_theme_mode } from '@/lib/set_theme_mode'
9
+ import { type ThemeMode } from '@/lib/types/ui_preferences'
10
+
11
+ const options: { value: ThemeMode; label: string; description: string }[] = [
12
+ { value: 'light', label: 'Light', description: 'Always use the light theme.' },
13
+ { value: 'dark', label: 'Dark', description: 'Always use the dark theme.' },
14
+ {
15
+ value: 'system',
16
+ label: 'System',
17
+ description: 'Match the operating system preference.',
18
+ },
19
+ ]
20
+
21
+ /**
22
+ * Setting: light / dark / system theme preference.
23
+ */
24
+ export function ThemeModeSetting() {
25
+ const mode = useSyncExternalStore(
26
+ theme_mode_preference.subscribe,
27
+ theme_mode_preference.get_snapshot,
28
+ theme_mode_preference.get_server_snapshot,
29
+ )
30
+
31
+ return (
32
+ <SettingRadioGroup<ThemeMode>
33
+ name="theme-mode"
34
+ legend="Light / dark mode"
35
+ description="Choose light, dark, or match the system. The topbar toggle flips light and dark."
36
+ value={mode}
37
+ options={options}
38
+ on_change={(mode) => {
39
+ set_theme_mode(mode)
40
+ notify_settings_saved()
41
+ }}
42
+ />
43
+ )
44
+ }
@@ -0,0 +1,43 @@
1
+ 'use client'
2
+
3
+ import { useEffect } from 'react'
4
+
5
+ import { apply_theme } from '@/lib/apply_theme'
6
+ import { notify_theme_subscribers } from '@/lib/subscribe_theme'
7
+ import { resolve_theme_mode_to_theme } from '@/lib/resolve_theme_mode_to_theme'
8
+ import { theme_mode_preference } from '@/lib/preferences/theme_mode_preference'
9
+ import { write_stored_theme } from '@/lib/write_stored_theme'
10
+
11
+ /**
12
+ * Listens to OS-level theme changes and re-applies the theme when the
13
+ * user's mode preference is "system".
14
+ */
15
+ export function ThemeModeSystemListener() {
16
+ useEffect(() => {
17
+ if (typeof window === 'undefined') {
18
+ return
19
+ }
20
+
21
+ const media = window.matchMedia('(prefers-color-scheme: light)')
22
+
23
+ const handle_change = (): void => {
24
+ if (theme_mode_preference.read() !== 'system') {
25
+ return
26
+ }
27
+
28
+ const resolved = resolve_theme_mode_to_theme('system')
29
+
30
+ apply_theme(resolved)
31
+ write_stored_theme(resolved)
32
+ notify_theme_subscribers()
33
+ }
34
+
35
+ media.addEventListener('change', handle_change)
36
+
37
+ return () => {
38
+ media.removeEventListener('change', handle_change)
39
+ }
40
+ }, [])
41
+
42
+ return null
43
+ }
@@ -0,0 +1,38 @@
1
+ 'use client'
2
+
3
+ import { useSyncExternalStore } from 'react'
4
+
5
+ import { get_theme_server_snapshot, get_theme_snapshot } from '@/lib/get_theme_snapshot'
6
+ import { subscribe_theme } from '@/lib/subscribe_theme'
7
+ import { toggle_theme } from '@/lib/toggle_theme'
8
+
9
+ /**
10
+ * Toggles between light and dark themes.
11
+ */
12
+ export function ThemeSwitcher() {
13
+ const theme = useSyncExternalStore(
14
+ subscribe_theme,
15
+ get_theme_snapshot,
16
+ get_theme_server_snapshot,
17
+ )
18
+
19
+ const active_label = theme === 'dark' ? 'Dark' : 'Light'
20
+ const switch_label =
21
+ theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'
22
+
23
+ return (
24
+ <button
25
+ type="button"
26
+ className="inline-flex cursor-pointer items-center gap-1.5 rounded-full border border-panel-border bg-ghost-bg px-3 py-1.5 font-inherit text-[0.85rem] font-semibold text-inherit hover:bg-surface-hover disabled:cursor-wait disabled:opacity-60"
27
+ onClick={toggle_theme}
28
+ aria-label={`${active_label} theme. ${switch_label}`}
29
+ title={switch_label}
30
+ suppressHydrationWarning
31
+ >
32
+ <span className="text-base leading-none" aria-hidden="true">
33
+ {theme === 'dark' ? '☾' : '☀'}
34
+ </span>
35
+ <span suppressHydrationWarning>{active_label}</span>
36
+ </button>
37
+ )
38
+ }
@@ -0,0 +1,39 @@
1
+ 'use client'
2
+
3
+ import { useSyncExternalStore } from 'react'
4
+
5
+ import { SettingRadioGroup } from '@/components/setting-radio-group'
6
+ import { time_format_preference } from '@/lib/preferences/time_format_preference'
7
+ import { persist_ui_preference } from '@/lib/persist_ui_preference'
8
+ import { type TimeFormat } from '@/lib/types/ui_preferences'
9
+
10
+ const options: { value: TimeFormat; label: string; description: string }[] = [
11
+ { value: '12h', label: '12-hour', description: 'e.g. 6:34 PM' },
12
+ { value: '24h', label: '24-hour', description: 'e.g. 18:34' },
13
+ ]
14
+
15
+ const set_time_format = (value: TimeFormat): void => {
16
+ persist_ui_preference(time_format_preference, value)
17
+ }
18
+
19
+ /**
20
+ * Setting: 12-hour vs 24-hour time display.
21
+ */
22
+ export function TimeFormatSetting() {
23
+ const value = useSyncExternalStore(
24
+ time_format_preference.subscribe,
25
+ time_format_preference.get_snapshot,
26
+ time_format_preference.get_server_snapshot,
27
+ )
28
+
29
+ return (
30
+ <SettingRadioGroup<TimeFormat>
31
+ name="time-format"
32
+ legend="Time format"
33
+ description="Used for entry start/end times and notes timestamps."
34
+ value={value}
35
+ options={options}
36
+ on_change={set_time_format}
37
+ />
38
+ )
39
+ }
@@ -0,0 +1,38 @@
1
+ 'use client'
2
+
3
+ import { useSyncExternalStore } from 'react'
4
+
5
+ import { timer_in_title_preference } from '@/lib/preferences/timer_in_title_preference'
6
+ import { persist_ui_preference } from '@/lib/persist_ui_preference'
7
+
8
+ const set_timer_in_title = (enabled: boolean): void => {
9
+ persist_ui_preference(timer_in_title_preference, enabled ? 'true' : 'false')
10
+ }
11
+
12
+ /**
13
+ * Setting: show the live timer in the browser tab title.
14
+ */
15
+ export function TimerInTitleSetting() {
16
+ const value = useSyncExternalStore(
17
+ timer_in_title_preference.subscribe,
18
+ timer_in_title_preference.get_snapshot,
19
+ timer_in_title_preference.get_server_snapshot,
20
+ )
21
+
22
+ return (
23
+ <label className="flex w-full cursor-pointer items-center gap-2.5">
24
+ <input
25
+ type="checkbox"
26
+ className="shrink-0"
27
+ checked={value === 'true'}
28
+ onChange={(event) => set_timer_in_title(event.target.checked)}
29
+ />
30
+ <span className="flex flex-col gap-0.5">
31
+ <span className="text-[0.95rem] font-semibold">Timer in tab title</span>
32
+ <span className="text-[0.8rem] leading-snug text-muted">
33
+ Show elapsed time in the browser tab while a timer is running.
34
+ </span>
35
+ </span>
36
+ </label>
37
+ )
38
+ }
@@ -0,0 +1,41 @@
1
+ 'use client'
2
+
3
+ import { useSyncExternalStore } from 'react'
4
+
5
+ import { timer_show_seconds_preference } from '@/lib/preferences/timer_show_seconds_preference'
6
+ import { persist_ui_preference } from '@/lib/persist_ui_preference'
7
+
8
+ const set_timer_show_seconds = (enabled: boolean): void => {
9
+ persist_ui_preference(
10
+ timer_show_seconds_preference,
11
+ enabled ? 'true' : 'false',
12
+ )
13
+ }
14
+
15
+ /**
16
+ * Setting: show seconds on the active timer display.
17
+ */
18
+ export function TimerShowSecondsSetting() {
19
+ const value = useSyncExternalStore(
20
+ timer_show_seconds_preference.subscribe,
21
+ timer_show_seconds_preference.get_snapshot,
22
+ timer_show_seconds_preference.get_server_snapshot,
23
+ )
24
+
25
+ return (
26
+ <label className="flex w-full cursor-pointer items-center gap-2.5">
27
+ <input
28
+ type="checkbox"
29
+ className="shrink-0"
30
+ checked={value === 'true'}
31
+ onChange={(event) => set_timer_show_seconds(event.target.checked)}
32
+ />
33
+ <span className="flex flex-col gap-0.5">
34
+ <span className="text-[0.95rem] font-semibold">Show seconds on timer</span>
35
+ <span className="text-[0.8rem] leading-snug text-muted">
36
+ Include seconds on the live active timer (humanized duration format).
37
+ </span>
38
+ </span>
39
+ </label>
40
+ )
41
+ }
@@ -0,0 +1,76 @@
1
+ 'use client'
2
+
3
+ import { ActiveEntryPanel } from '@/components/active-entry-panel'
4
+ import { type EntryEditFormValues } from '@/components/entry-edit-form'
5
+ import {
6
+ type SerializedEntry,
7
+ type SerializedSheet,
8
+ } from '@/lib/types/tracker_state'
9
+
10
+ interface TrackerActiveBarProps {
11
+ active_entry: SerializedEntry | null
12
+ sheets: SerializedSheet[]
13
+ is_pending: boolean
14
+ on_check_out: (at?: string) => void
15
+ on_delete: () => void
16
+ on_edit: (values: EntryEditFormValues) => void
17
+ on_move: (target_sheet_name: string) => void
18
+ on_add_note: (text: string, at?: string) => void
19
+ on_edit_note: (timestamp: string, text: string) => void
20
+ on_delete_note: (timestamp: string) => void
21
+ }
22
+
23
+ const section_label_class =
24
+ 'text-[0.72rem] font-semibold uppercase tracking-[0.04em] text-muted whitespace-nowrap'
25
+
26
+ const tracking_pill_class =
27
+ 'shrink-0 rounded-full bg-accent px-2 py-0.5 text-[0.68rem] font-bold uppercase leading-none tracking-wider text-accent-text-on'
28
+
29
+ /**
30
+ * Full-width header region for the active sheet and running entry controls.
31
+ */
32
+ export function TrackerActiveBar({
33
+ active_entry,
34
+ sheets,
35
+ is_pending,
36
+ on_check_out,
37
+ on_delete,
38
+ on_edit,
39
+ on_move,
40
+ on_add_note,
41
+ on_edit_note,
42
+ on_delete_note,
43
+ }: TrackerActiveBarProps) {
44
+ return (
45
+ <div className="w-full border-b border-panel-border bg-[color-mix(in_srgb,var(--accent-soft)_55%,var(--panel))]">
46
+ <div className="mx-auto flex w-full max-w-[1120px] flex-col gap-3 px-5 py-3.5 max-[860px]:gap-2.5 max-[860px]:py-3">
47
+ {active_entry !== null ? (
48
+ <div className="flex flex-col gap-2">
49
+ <div className="flex min-w-0 flex-wrap items-center gap-2">
50
+ <span className={`${section_label_class} truncate`}>
51
+ Sheet {active_entry.sheetName}
52
+ </span>
53
+ <span className={tracking_pill_class}>Tracking</span>
54
+ </div>
55
+ <ActiveEntryPanel
56
+ key={`${active_entry.sheetName}-${active_entry.id}`}
57
+ entry={active_entry}
58
+ sheets={sheets}
59
+ in_bar
60
+ is_pending={is_pending}
61
+ on_check_out={on_check_out}
62
+ on_delete={on_delete}
63
+ on_edit={on_edit}
64
+ on_move={on_move}
65
+ on_add_note={on_add_note}
66
+ on_edit_note={on_edit_note}
67
+ on_delete_note={on_delete_note}
68
+ />
69
+ </div>
70
+ ) : (
71
+ <p className="m-0 text-[0.85rem] leading-tight text-muted">Not tracking</p>
72
+ )}
73
+ </div>
74
+ </div>
75
+ )
76
+ }