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,199 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+
5
+ import { CheckoutButtonGroup } from '@/components/checkout-button-group'
6
+ import { use_confirm_dialog } from '@/components/confirm-dialog-provider'
7
+ import { EntryActionsMenu } from '@/components/entry-actions-menu'
8
+ import { EntryEditForm, type EntryEditFormValues } from '@/components/entry-edit-form'
9
+ import { EntryNotesList } from '@/components/entry-notes-list'
10
+ import { NoteForm } from '@/components/note-form'
11
+ import { format_display_tag } from '@/lib/format_display_tag'
12
+ import { format_duration } from '@/lib/format_duration'
13
+ import { use_confirm_destructive_actions } from '@/lib/use_confirm_destructive_actions'
14
+ import { use_duration_format } from '@/lib/use_duration_format'
15
+ import { use_timer_show_seconds } from '@/lib/use_timer_show_seconds'
16
+ import { get_button_class_name } from '@/lib/get_button_class_name'
17
+ import { get_delete_entry_confirm_dialog } from '@/lib/get_delete_entry_confirm_dialog'
18
+ import { get_delete_note_confirm_dialog } from '@/lib/get_delete_note_confirm_dialog'
19
+ import { get_active_panel_class_name } from '@/lib/get_active_panel_class_name'
20
+ import {
21
+ type SerializedEntry,
22
+ type SerializedSheet,
23
+ } from '@/lib/types/tracker_state'
24
+
25
+ interface ActiveEntryPanelProps {
26
+ entry: SerializedEntry
27
+ sheets: SerializedSheet[]
28
+ in_bar?: boolean
29
+ on_check_out: (at?: string) => void
30
+ on_delete: () => void
31
+ on_edit: (values: EntryEditFormValues) => void
32
+ on_move: (target_sheet_name: string) => void
33
+ on_add_note: (text: string, at?: string) => void
34
+ on_edit_note: (timestamp: string, text: string) => void
35
+ on_delete_note: (timestamp: string) => void
36
+ is_pending: boolean
37
+ }
38
+
39
+ const tag_item_class =
40
+ 'rounded-full bg-tag-bg px-2 py-0.5 text-xs text-tag-text'
41
+
42
+ /**
43
+ * Shows the running active entry with a live duration timer.
44
+ */
45
+ export function ActiveEntryPanel({
46
+ entry,
47
+ sheets,
48
+ in_bar = false,
49
+ on_check_out,
50
+ on_delete,
51
+ on_edit,
52
+ on_move,
53
+ on_add_note,
54
+ on_edit_note,
55
+ on_delete_note,
56
+ is_pending,
57
+ }: ActiveEntryPanelProps) {
58
+ const { confirm } = use_confirm_dialog()
59
+ const confirm_destructive_actions = use_confirm_destructive_actions()
60
+ const duration_format = use_duration_format()
61
+ const show_seconds = use_timer_show_seconds()
62
+ const [duration_ms, set_duration_ms] = useState(entry.durationMs)
63
+ const [is_editing, set_is_editing] = useState(false)
64
+ const [is_adding_note, set_is_adding_note] = useState(false)
65
+
66
+ useEffect(() => {
67
+ set_is_adding_note(false)
68
+ }, [entry.id, entry.sheetName])
69
+
70
+ useEffect(() => {
71
+ set_duration_ms(entry.durationMs)
72
+
73
+ const interval = window.setInterval(() => {
74
+ set_duration_ms(Date.now() - new Date(entry.start).getTime())
75
+ }, 1000)
76
+
77
+ return () => window.clearInterval(interval)
78
+ }, [entry.durationMs, entry.start])
79
+
80
+ const panel_class = get_active_panel_class_name(in_bar, is_editing)
81
+
82
+ const handle_delete_note = async (timestamp: string): Promise<void> => {
83
+ const note = entry.notes.find((item) => item.timestamp === timestamp)
84
+ const confirmed = confirm_destructive_actions
85
+ ? await confirm(get_delete_note_confirm_dialog(note?.text ?? ''))
86
+ : true
87
+
88
+ if (confirmed) {
89
+ on_delete_note(timestamp)
90
+ }
91
+ }
92
+
93
+ if (is_editing) {
94
+ return (
95
+ <section className={panel_class}>
96
+ <EntryEditForm
97
+ entry={entry}
98
+ is_pending={is_pending}
99
+ in_active_panel
100
+ on_cancel={() => set_is_editing(false)}
101
+ on_save={(values) => {
102
+ on_edit(values)
103
+ set_is_editing(false)
104
+ }}
105
+ />
106
+ </section>
107
+ )
108
+ }
109
+
110
+ return (
111
+ <section className={panel_class}>
112
+ <div className="flex items-start justify-between gap-3">
113
+ <div className="flex min-w-0 flex-col gap-1.5">
114
+ {!in_bar ? (
115
+ <span className="self-start rounded-full bg-accent px-2 py-0.5 text-[0.68rem] font-bold uppercase leading-none tracking-wider text-accent-text-on">
116
+ Tracking
117
+ </span>
118
+ ) : null}
119
+ <h2 className="m-0 text-xl font-[650] leading-tight tracking-tight">
120
+ {entry.description || 'Untitled entry'}
121
+ </h2>
122
+ </div>
123
+ <EntryActionsMenu
124
+ current_sheet_name={entry.sheetName}
125
+ sheets={sheets}
126
+ is_pending={is_pending}
127
+ on_edit={() => set_is_editing(true)}
128
+ on_show_add_note_form={() => set_is_adding_note(true)}
129
+ on_move={on_move}
130
+ on_delete={async () => {
131
+ const confirmed = confirm_destructive_actions
132
+ ? await confirm(get_delete_entry_confirm_dialog(entry))
133
+ : true
134
+
135
+ if (confirmed) {
136
+ on_delete()
137
+ }
138
+ }}
139
+ />
140
+ </div>
141
+ <div className="flex items-end justify-between gap-4 max-[860px]:flex-col max-[860px]:items-stretch">
142
+ <div className="flex min-w-0 flex-col gap-2">
143
+ <p className="m-0 font-mono text-[2rem] font-medium leading-none tracking-tight text-accent">
144
+ {format_duration(duration_ms, duration_format, show_seconds)}
145
+ </p>
146
+ {entry.tags.length > 0 ? (
147
+ <ul className="m-0 flex list-none flex-wrap gap-1.5 p-0">
148
+ {entry.tags.map((tag) => (
149
+ <li key={tag} className={tag_item_class}>
150
+ {format_display_tag(tag)}
151
+ </li>
152
+ ))}
153
+ </ul>
154
+ ) : null}
155
+ </div>
156
+ <div
157
+ className={`inline-flex shrink-0 items-center gap-2 ${in_bar ? 'min-w-0 max-[860px]:w-full max-[860px]:justify-end' : 'min-w-30 max-[860px]:w-full max-[860px]:justify-stretch'}`}
158
+ >
159
+ {!is_adding_note ? (
160
+ <button
161
+ type="button"
162
+ className={`${get_button_class_name('ghost')} max-[860px]:flex-1`}
163
+ disabled={is_pending}
164
+ onClick={() => set_is_adding_note(true)}
165
+ >
166
+ Add note
167
+ </button>
168
+ ) : null}
169
+ <CheckoutButtonGroup
170
+ in_bar={in_bar}
171
+ is_pending={is_pending}
172
+ on_check_out={on_check_out}
173
+ />
174
+ </div>
175
+ </div>
176
+ <EntryNotesList
177
+ notes={entry.notes}
178
+ variant="panel"
179
+ in_bar={in_bar}
180
+ is_pending={is_pending}
181
+ on_edit_note={on_edit_note}
182
+ on_delete_note={handle_delete_note}
183
+ />
184
+ {is_adding_note ? (
185
+ <NoteForm
186
+ in_active_panel
187
+ in_bar={in_bar}
188
+ allow_at
189
+ is_pending={is_pending}
190
+ on_cancel={() => set_is_adding_note(false)}
191
+ on_submit={(text, at) => {
192
+ on_add_note(text, at)
193
+ set_is_adding_note(false)
194
+ }}
195
+ />
196
+ ) : null}
197
+ </section>
198
+ )
199
+ }
@@ -0,0 +1,168 @@
1
+ 'use client'
2
+
3
+ import { useRouter } from 'next/navigation'
4
+ import { useRef, useState } from 'react'
5
+
6
+ import { use_confirm_dialog } from '@/components/confirm-dialog-provider'
7
+ import { get_button_class_name } from '@/lib/get_button_class_name'
8
+ import { get_restore_db_confirm_dialog } from '@/lib/get_restore_db_confirm_dialog'
9
+
10
+ interface BackupRestoreSettingProps {
11
+ db_path: string
12
+ }
13
+
14
+ /**
15
+ * Downloads or restores the tracker database from Settings.
16
+ */
17
+ export function BackupRestoreSetting({ db_path }: BackupRestoreSettingProps) {
18
+ const router = useRouter()
19
+ const { confirm } = use_confirm_dialog()
20
+ const file_input_ref = useRef<HTMLInputElement>(null)
21
+ const [error, set_error] = useState<string | null>(null)
22
+ const [status_message, set_status_message] = useState<string | null>(null)
23
+ const [is_downloading, set_is_downloading] = useState(false)
24
+ const [is_restoring, set_is_restoring] = useState(false)
25
+
26
+ const handle_download = async (): Promise<void> => {
27
+ set_is_downloading(true)
28
+ set_error(null)
29
+ set_status_message(null)
30
+
31
+ try {
32
+ const response = await fetch('/api/backup')
33
+
34
+ if (!response.ok) {
35
+ const body = (await response.json()) as { error?: string }
36
+ throw new Error(body.error ?? 'Download failed')
37
+ }
38
+
39
+ const blob = await response.blob()
40
+ const url = URL.createObjectURL(blob)
41
+ const link = document.createElement('a')
42
+
43
+ link.href = url
44
+ link.download = 'db.json'
45
+ link.click()
46
+ URL.revokeObjectURL(url)
47
+ set_status_message('Backup downloaded.')
48
+ } catch (download_error: unknown) {
49
+ set_error(
50
+ download_error instanceof Error
51
+ ? download_error.message
52
+ : String(download_error),
53
+ )
54
+ } finally {
55
+ set_is_downloading(false)
56
+ }
57
+ }
58
+
59
+ const handle_restore_click = (): void => {
60
+ file_input_ref.current?.click()
61
+ }
62
+
63
+ const handle_file_change = async (
64
+ event: React.ChangeEvent<HTMLInputElement>,
65
+ ): Promise<void> => {
66
+ const file = event.target.files?.[0]
67
+
68
+ event.target.value = ''
69
+
70
+ if (file === undefined) {
71
+ return
72
+ }
73
+
74
+ const confirmed = await confirm(get_restore_db_confirm_dialog())
75
+
76
+ if (!confirmed) {
77
+ return
78
+ }
79
+
80
+ set_is_restoring(true)
81
+ set_error(null)
82
+ set_status_message(null)
83
+
84
+ try {
85
+ const text = await file.text()
86
+ let uploaded: unknown
87
+
88
+ try {
89
+ uploaded = JSON.parse(text)
90
+ } catch {
91
+ throw new Error('Invalid backup file: file is not valid JSON.')
92
+ }
93
+ const response = await fetch('/api/backup', {
94
+ method: 'POST',
95
+ headers: { 'Content-Type': 'application/json' },
96
+ body: JSON.stringify(uploaded),
97
+ })
98
+
99
+ if (!response.ok) {
100
+ const body = (await response.json()) as { error?: string }
101
+ throw new Error(body.error ?? 'Restore failed')
102
+ }
103
+
104
+ set_status_message('Backup restored. Opening tracker…')
105
+ router.push('/')
106
+ router.refresh()
107
+ } catch (restore_error: unknown) {
108
+ set_error(
109
+ restore_error instanceof Error
110
+ ? restore_error.message
111
+ : String(restore_error),
112
+ )
113
+ } finally {
114
+ set_is_restoring(false)
115
+ }
116
+ }
117
+
118
+ const is_busy = is_downloading || is_restoring
119
+
120
+ return (
121
+ <div className="flex w-full flex-col gap-3">
122
+ <div className="flex flex-col gap-0.5">
123
+ <h2 className="m-0 text-[0.95rem] font-semibold">Backup and restore</h2>
124
+ <p className="m-0 text-[0.8rem] leading-snug text-muted">
125
+ Download a copy of your database or replace it with a previously saved
126
+ backup file.
127
+ </p>
128
+ </div>
129
+ <p
130
+ className="m-0 overflow-wrap-anywhere font-mono text-[0.65rem] leading-snug text-muted"
131
+ title={db_path}
132
+ >
133
+ {db_path}
134
+ </p>
135
+ <div className="flex flex-wrap gap-2">
136
+ <button
137
+ type="button"
138
+ className={get_button_class_name('ghost', 'small')}
139
+ disabled={is_busy}
140
+ onClick={() => void handle_download()}
141
+ >
142
+ {is_downloading ? 'Downloading…' : 'Download backup'}
143
+ </button>
144
+ <button
145
+ type="button"
146
+ className={get_button_class_name('danger', 'small')}
147
+ disabled={is_busy}
148
+ onClick={handle_restore_click}
149
+ >
150
+ {is_restoring ? 'Restoring…' : 'Restore from file'}
151
+ </button>
152
+ <input
153
+ ref={file_input_ref}
154
+ type="file"
155
+ accept="application/json,.json"
156
+ className="hidden"
157
+ onChange={(event) => void handle_file_change(event)}
158
+ />
159
+ </div>
160
+ {status_message !== null ? (
161
+ <p className="m-0 text-[0.82rem] text-accent">{status_message}</p>
162
+ ) : null}
163
+ {error !== null ? (
164
+ <p className="m-0 text-[0.82rem] text-danger">{error}</p>
165
+ ) : null}
166
+ </div>
167
+ )
168
+ }
@@ -0,0 +1,44 @@
1
+ 'use client'
2
+
3
+ import { useSyncExternalStore } from 'react'
4
+
5
+ import { check_in_form_collapsed_preference } from '@/lib/preferences/check_in_form_collapsed_preference'
6
+ import { persist_ui_preference } from '@/lib/persist_ui_preference'
7
+
8
+ const set_check_in_form_collapsed = (collapsed: boolean): void => {
9
+ persist_ui_preference(
10
+ check_in_form_collapsed_preference,
11
+ collapsed ? 'true' : 'false',
12
+ )
13
+ }
14
+
15
+ /**
16
+ * Setting: collapse the check-in form into a single button by default.
17
+ */
18
+ export function CheckInFormCollapsedSetting() {
19
+ const value = useSyncExternalStore(
20
+ check_in_form_collapsed_preference.subscribe,
21
+ check_in_form_collapsed_preference.get_snapshot,
22
+ check_in_form_collapsed_preference.get_server_snapshot,
23
+ )
24
+ const is_collapsed = value === 'true'
25
+
26
+ return (
27
+ <label className="flex w-full cursor-pointer items-center gap-2.5">
28
+ <input
29
+ type="checkbox"
30
+ className="shrink-0"
31
+ checked={is_collapsed}
32
+ onChange={(event) => set_check_in_form_collapsed(event.target.checked)}
33
+ />
34
+ <span className="flex flex-col gap-0.5">
35
+ <span className="text-[0.95rem] font-semibold">
36
+ Collapse check-in form
37
+ </span>
38
+ <span className="text-[0.8rem] leading-snug text-muted">
39
+ Show a single &ldquo;Check in&rdquo; button until clicked, instead of the full form.
40
+ </span>
41
+ </span>
42
+ </label>
43
+ )
44
+ }
@@ -0,0 +1,52 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+
5
+ import { CheckInForm, type CheckInFormValues } from '@/components/check-in-form'
6
+ import { get_button_class_name } from '@/lib/get_button_class_name'
7
+ import { use_check_in_form_collapsed } from '@/lib/use_check_in_form_collapsed'
8
+
9
+ interface CheckInFormCollapsibleProps {
10
+ known_tags: string[]
11
+ is_pending: boolean
12
+ on_submit: (values: CheckInFormValues) => void
13
+ }
14
+
15
+ /**
16
+ * Renders the check-in form, collapsible to a button when the preference is set.
17
+ */
18
+ export function CheckInFormCollapsible({
19
+ known_tags,
20
+ is_pending,
21
+ on_submit,
22
+ }: CheckInFormCollapsibleProps) {
23
+ const should_collapse_by_default = use_check_in_form_collapsed()
24
+ const [is_expanded, set_is_expanded] = useState(!should_collapse_by_default)
25
+
26
+ if (should_collapse_by_default && !is_expanded) {
27
+ return (
28
+ <button
29
+ type="button"
30
+ className={`${get_button_class_name('primary')} self-start`}
31
+ disabled={is_pending}
32
+ onClick={() => set_is_expanded(true)}
33
+ >
34
+ Check in
35
+ </button>
36
+ )
37
+ }
38
+
39
+ return (
40
+ <CheckInForm
41
+ known_tags={known_tags}
42
+ is_pending={is_pending}
43
+ on_submit={(values) => {
44
+ on_submit(values)
45
+
46
+ if (should_collapse_by_default) {
47
+ set_is_expanded(false)
48
+ }
49
+ }}
50
+ />
51
+ )
52
+ }
@@ -0,0 +1,89 @@
1
+ 'use client'
2
+
3
+ import { type FormEvent, useState } from 'react'
4
+
5
+ import { TagAutocompleteInput } from '@/components/tag-autocomplete-input'
6
+ import { get_button_class_name } from '@/lib/get_button_class_name'
7
+ import { get_input_class_name } from '@/lib/get_input_class_name'
8
+
9
+ export interface CheckInFormValues {
10
+ description: string
11
+ at?: string
12
+ }
13
+
14
+ interface CheckInFormProps {
15
+ known_tags: string[]
16
+ on_submit: (values: CheckInFormValues) => void
17
+ is_pending: boolean
18
+ }
19
+
20
+ /**
21
+ * Form for starting a new time sheet entry.
22
+ */
23
+ export function CheckInForm({
24
+ known_tags,
25
+ on_submit,
26
+ is_pending,
27
+ }: CheckInFormProps) {
28
+ const [description, set_description] = useState('')
29
+ const [at, set_at] = useState('')
30
+
31
+ const handle_submit = (event: FormEvent<HTMLFormElement>): void => {
32
+ event.preventDefault()
33
+ const trimmed_description = description.trim()
34
+
35
+ if (trimmed_description.length === 0) {
36
+ return
37
+ }
38
+
39
+ const trimmed_at = at.trim()
40
+
41
+ on_submit({
42
+ description: trimmed_description,
43
+ ...(trimmed_at.length > 0 ? { at: trimmed_at } : {}),
44
+ })
45
+ set_description('')
46
+ set_at('')
47
+ }
48
+
49
+ return (
50
+ <form
51
+ className="flex flex-col gap-2 rounded-lg border border-panel-border bg-panel p-[1.1rem] shadow-sm"
52
+ onSubmit={handle_submit}
53
+ >
54
+ <label className="text-[0.85rem] text-muted" htmlFor="check-in-description">
55
+ What are you working on?
56
+ </label>
57
+ <div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2 max-[860px]:grid-cols-1">
58
+ <TagAutocompleteInput
59
+ id="check-in-description"
60
+ value={description}
61
+ known_tags={known_tags}
62
+ placeholder="e.g. crafting something @design"
63
+ disabled={is_pending}
64
+ autoFocus
65
+ on_change={set_description}
66
+ />
67
+ <button
68
+ type="submit"
69
+ className={get_button_class_name('primary')}
70
+ disabled={is_pending || description.trim().length === 0}
71
+ >
72
+ Check in
73
+ </button>
74
+ </div>
75
+ <label className="text-[0.85rem] text-muted" htmlFor="check-in-at">
76
+ Start time{' '}
77
+ <span className="font-normal opacity-85">(optional, natural language)</span>
78
+ </label>
79
+ <input
80
+ id="check-in-at"
81
+ className={get_input_class_name()}
82
+ value={at}
83
+ onChange={(event) => set_at(event.target.value)}
84
+ placeholder="e.g. 30 minutes ago"
85
+ disabled={is_pending}
86
+ />
87
+ </form>
88
+ )
89
+ }
@@ -0,0 +1,75 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef } from 'react'
4
+
5
+ interface CheckboxProps {
6
+ checked: boolean
7
+ disabled?: boolean
8
+ indeterminate?: boolean
9
+ nested?: boolean
10
+ on_change: () => void
11
+ label?: string
12
+ aria_label?: string
13
+ className?: string
14
+ }
15
+
16
+ const root_base =
17
+ 'inline-flex shrink-0 cursor-pointer items-center gap-1.5'
18
+
19
+ const control_class =
20
+ 'relative block h-[0.85rem] w-[0.85rem] shrink-0 rounded-[0.2rem] border border-panel-border bg-input-bg box-border transition-[background-color,border-color] duration-150 peer-checked:border-accent peer-checked:bg-accent peer-indeterminate:border-accent peer-indeterminate:bg-accent peer-focus-visible:outline peer-focus-visible:outline-2 peer-focus-visible:outline-input-focus-border peer-focus-visible:outline-offset-1 peer-disabled:cursor-not-allowed peer-disabled:opacity-55 after:absolute after:left-1/2 after:top-[44%] after:hidden after:h-[0.28rem] after:w-[0.45rem] after:-translate-x-1/2 after:-translate-y-[60%] after:rotate-[-45deg] after:border-b-[1.5px] after:border-l-[1.5px] after:border-accent-text-on after:content-[""] peer-checked:after:block peer-indeterminate:after:top-1/2 peer-indeterminate:after:block peer-indeterminate:after:h-[1.5px] peer-indeterminate:after:w-[0.45rem] peer-indeterminate:after:-translate-x-1/2 peer-indeterminate:after:-translate-y-1/2 peer-indeterminate:after:rotate-0 peer-indeterminate:after:border-0 peer-indeterminate:after:bg-accent-text-on'
21
+
22
+ /**
23
+ * Accessible custom-styled checkbox control.
24
+ */
25
+ export function Checkbox({
26
+ checked,
27
+ disabled = false,
28
+ indeterminate = false,
29
+ nested = false,
30
+ on_change,
31
+ label,
32
+ aria_label,
33
+ className,
34
+ }: CheckboxProps) {
35
+ const input_ref = useRef<HTMLInputElement>(null)
36
+
37
+ useEffect(() => {
38
+ if (input_ref.current !== null) {
39
+ input_ref.current.indeterminate = indeterminate
40
+ }
41
+ }, [indeterminate])
42
+
43
+ const root_class =
44
+ className === undefined ? root_base : `${root_base} ${className}`
45
+
46
+ const control = (
47
+ <>
48
+ <input
49
+ ref={input_ref}
50
+ type="checkbox"
51
+ className="peer absolute m-0 h-px w-px overflow-hidden opacity-0"
52
+ checked={checked}
53
+ disabled={disabled}
54
+ aria-label={nested && label === undefined ? aria_label : undefined}
55
+ onChange={on_change}
56
+ />
57
+ <span className={control_class} aria-hidden="true" />
58
+ </>
59
+ )
60
+
61
+ if (nested) {
62
+ return <span className={root_class}>{control}</span>
63
+ }
64
+
65
+ return (
66
+ <label className={root_class}>
67
+ {control}
68
+ {label !== undefined ? (
69
+ <span className="select-none text-[0.8rem] leading-tight text-muted">
70
+ {label}
71
+ </span>
72
+ ) : null}
73
+ </label>
74
+ )
75
+ }
@@ -0,0 +1,73 @@
1
+ 'use client'
2
+
3
+ import { use_confirm_dialog } from '@/components/confirm-dialog-provider'
4
+ import { get_button_class_name } from '@/lib/get_button_class_name'
5
+ import { get_check_out_confirm_dialog } from '@/lib/get_check_out_confirm_dialog'
6
+ import { prompt_check_out_at } from '@/lib/prompt_check_out_at'
7
+ import { use_confirm_before_checkout } from '@/lib/use_confirm_before_checkout'
8
+
9
+ interface CheckoutButtonGroupProps {
10
+ in_bar?: boolean
11
+ is_pending: boolean
12
+ on_check_out: (at?: string) => void
13
+ }
14
+
15
+ const group_button_class = `${get_button_class_name('danger')} rounded-none first:rounded-l-[0.65rem] last:rounded-r-[0.65rem] not-first:-ml-px not-first:min-w-12 not-first:border-l not-first:border-l-[color-mix(in_srgb,var(--danger-text)_30%,var(--background))] max-[860px]:flex-1 max-[860px]:basis-1/2`
16
+
17
+ /**
18
+ * Check out now or at a natural-language time in a joined button group.
19
+ */
20
+ export function CheckoutButtonGroup({
21
+ in_bar = false,
22
+ is_pending,
23
+ on_check_out,
24
+ }: CheckoutButtonGroupProps) {
25
+ const { confirm } = use_confirm_dialog()
26
+ const confirm_before_checkout = use_confirm_before_checkout()
27
+
28
+ const check_out_with_confirm = async (at?: string): Promise<void> => {
29
+ if (confirm_before_checkout) {
30
+ const confirmed = await confirm(get_check_out_confirm_dialog(at))
31
+
32
+ if (!confirmed) {
33
+ return
34
+ }
35
+ }
36
+
37
+ on_check_out(at)
38
+ }
39
+
40
+ const handle_at = (): void => {
41
+ const at = prompt_check_out_at()
42
+
43
+ if (at === null) {
44
+ return
45
+ }
46
+
47
+ void check_out_with_confirm(at)
48
+ }
49
+
50
+ return (
51
+ <div
52
+ className={`inline-flex shrink-0 ${in_bar ? 'min-w-0 max-[860px]:w-full' : 'min-w-30 max-[860px]:w-full'}`}
53
+ >
54
+ <button
55
+ type="button"
56
+ className={group_button_class}
57
+ disabled={is_pending}
58
+ onClick={() => void check_out_with_confirm()}
59
+ >
60
+ Check out
61
+ </button>
62
+ <button
63
+ type="button"
64
+ className={group_button_class}
65
+ disabled={is_pending}
66
+ title="Check out at a specific time"
67
+ onClick={handle_at}
68
+ >
69
+ @
70
+ </button>
71
+ </div>
72
+ )
73
+ }