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,41 @@
1
+ 'use client'
2
+
3
+ import { useSyncExternalStore } from 'react'
4
+
5
+ import { SettingRadioGroup } from '@/components/setting-radio-group'
6
+ import { entry_list_sort_preference } from '@/lib/preferences/entry_list_sort_preference'
7
+ import { persist_ui_preference } from '@/lib/persist_ui_preference'
8
+ import { type EntryListSort } from '@/lib/types/ui_preferences'
9
+
10
+ const options: { value: EntryListSort; label: string }[] = [
11
+ { value: 'newest', label: 'Newest first' },
12
+ { value: 'oldest', label: 'Oldest first' },
13
+ { value: 'duration', label: 'Longest duration' },
14
+ { value: 'description', label: 'Description (A–Z)' },
15
+ ]
16
+
17
+ const set_entry_list_sort = (value: EntryListSort): void => {
18
+ persist_ui_preference(entry_list_sort_preference, value)
19
+ }
20
+
21
+ /**
22
+ * Setting: default sort order for the entry list on the home view.
23
+ */
24
+ export function EntryListSortSetting() {
25
+ const value = useSyncExternalStore(
26
+ entry_list_sort_preference.subscribe,
27
+ entry_list_sort_preference.get_snapshot,
28
+ entry_list_sort_preference.get_server_snapshot,
29
+ )
30
+
31
+ return (
32
+ <SettingRadioGroup<EntryListSort>
33
+ name="entry-list-sort"
34
+ legend="Entry list sort"
35
+ description="How entries are ordered on the active sheet."
36
+ value={value}
37
+ options={options}
38
+ on_change={set_entry_list_sort}
39
+ />
40
+ )
41
+ }
@@ -0,0 +1,336 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+
5
+ import { Checkbox } from '@/components/checkbox'
6
+ import { use_confirm_dialog } from '@/components/confirm-dialog-provider'
7
+ import { EntryActionsMenu } from '@/components/entry-actions-menu'
8
+ import { EntryNotesList } from '@/components/entry-notes-list'
9
+ import { EntryEditForm, type EntryEditFormValues } from '@/components/entry-edit-form'
10
+ import { EntryListBulkBar } from '@/components/entry-list-bulk-bar'
11
+ import { format_time } from '@/components/format_time'
12
+ import { get_delete_entries_confirm_dialog } from '@/lib/get_delete_entries_confirm_dialog'
13
+ import { get_delete_entry_confirm_dialog } from '@/lib/get_delete_entry_confirm_dialog'
14
+ import { format_display_tag } from '@/lib/format_display_tag'
15
+ import { format_duration } from '@/lib/format_duration'
16
+ import { get_entry_row_key } from '@/lib/get_entry_row_key'
17
+ import { use_confirm_destructive_actions } from '@/lib/use_confirm_destructive_actions'
18
+ import { use_duration_format } from '@/lib/use_duration_format'
19
+ import { use_time_format } from '@/lib/use_time_format'
20
+ import {
21
+ type SerializedEntry,
22
+ type SerializedSheet,
23
+ } from '@/lib/types/tracker_state'
24
+
25
+ interface EntryListProps {
26
+ title: string
27
+ entries: SerializedEntry[]
28
+ sheets: SerializedSheet[]
29
+ total_ms: number
30
+ empty_message: string
31
+ is_pending: boolean
32
+ show_sheet_name?: boolean
33
+ on_delete: (entry: SerializedEntry) => void
34
+ on_edit: (entry: SerializedEntry, values: EntryEditFormValues) => void
35
+ on_move: (entry: SerializedEntry, target_sheet_name: string) => void
36
+ on_move_many: (
37
+ entries: SerializedEntry[],
38
+ target_sheet_name: string,
39
+ ) => void
40
+ on_delete_many: (entries: SerializedEntry[]) => void
41
+ on_edit_note: (
42
+ entry: SerializedEntry,
43
+ timestamp: string,
44
+ text: string,
45
+ ) => void
46
+ on_add_note: (entry: SerializedEntry, text: string) => void
47
+ on_resume: (entry: SerializedEntry) => void
48
+ }
49
+
50
+ const tag_item_class =
51
+ 'rounded-full bg-tag-bg px-2 py-0.5 text-xs text-tag-text'
52
+
53
+ /**
54
+ * Renders a list of time sheet entries with edit and delete actions.
55
+ */
56
+ export function EntryList({
57
+ title,
58
+ entries,
59
+ sheets,
60
+ total_ms,
61
+ empty_message,
62
+ is_pending,
63
+ show_sheet_name = true,
64
+ on_delete,
65
+ on_edit,
66
+ on_move,
67
+ on_move_many,
68
+ on_delete_many,
69
+ on_edit_note,
70
+ on_add_note,
71
+ on_resume,
72
+ }: EntryListProps) {
73
+ const { confirm } = use_confirm_dialog()
74
+ const confirm_destructive_actions = use_confirm_destructive_actions()
75
+ const time_format = use_time_format()
76
+ const duration_format = use_duration_format()
77
+ const [editing_key, set_editing_key] = useState<string | null>(null)
78
+ const [selected_keys, set_selected_keys] = useState<Set<string>>(() => new Set())
79
+
80
+ const entry_keys = entries.map((entry) => get_entry_row_key(entry))
81
+ const selected_entries = entries.filter((entry) =>
82
+ selected_keys.has(get_entry_row_key(entry)),
83
+ )
84
+ const all_selected =
85
+ entries.length > 0 && selected_entries.length === entries.length
86
+ const some_selected =
87
+ selected_entries.length > 0 && selected_entries.length < entries.length
88
+
89
+ useEffect(() => {
90
+ set_selected_keys((previous) => {
91
+ const valid_keys = new Set(entry_keys)
92
+ const next = new Set(
93
+ [...previous].filter((key) => valid_keys.has(key)),
94
+ )
95
+
96
+ return next.size === previous.size ? previous : next
97
+ })
98
+ }, [entry_keys.join('|')])
99
+
100
+ const toggle_entry = (row_key: string): void => {
101
+ set_selected_keys((previous) => {
102
+ const next = new Set(previous)
103
+
104
+ if (next.has(row_key)) {
105
+ next.delete(row_key)
106
+ } else {
107
+ next.add(row_key)
108
+ }
109
+
110
+ return next
111
+ })
112
+ }
113
+
114
+ const toggle_all = (): void => {
115
+ if (all_selected) {
116
+ set_selected_keys(new Set())
117
+ return
118
+ }
119
+
120
+ set_selected_keys(new Set(entry_keys))
121
+ }
122
+
123
+ const clear_selection = (): void => {
124
+ set_selected_keys(new Set())
125
+ }
126
+
127
+ const handle_bulk_move = (target_sheet_name: string): void => {
128
+ on_move_many(selected_entries, target_sheet_name)
129
+ clear_selection()
130
+ }
131
+
132
+ const handle_bulk_delete = async (): Promise<void> => {
133
+ if (selected_entries.length === 0) {
134
+ return
135
+ }
136
+
137
+ const confirmed = confirm_destructive_actions
138
+ ? await confirm(get_delete_entries_confirm_dialog(selected_entries))
139
+ : true
140
+
141
+ if (!confirmed) {
142
+ return
143
+ }
144
+
145
+ on_delete_many(selected_entries)
146
+ clear_selection()
147
+ }
148
+
149
+ const has_selection = selected_entries.length > 0
150
+
151
+ return (
152
+ <section className="min-w-0">
153
+ <header className="mb-3 flex flex-col gap-2 border-b border-panel-border pb-2.5 compact:mb-2 compact:pb-1.5">
154
+ {has_selection ? (
155
+ <EntryListBulkBar
156
+ selected_count={selected_entries.length}
157
+ total_count={entries.length}
158
+ all_selected={all_selected}
159
+ some_selected={some_selected}
160
+ selected_entries={selected_entries}
161
+ sheets={sheets}
162
+ is_pending={is_pending}
163
+ on_toggle_all={toggle_all}
164
+ on_move={handle_bulk_move}
165
+ on_delete={() => void handle_bulk_delete()}
166
+ on_clear={clear_selection}
167
+ />
168
+ ) : (
169
+ <div className="flex items-center justify-between gap-3">
170
+ <div className="flex min-w-0 items-center gap-2.5">
171
+ {entries.length > 0 ? (
172
+ <Checkbox
173
+ className="shrink-0"
174
+ checked={all_selected}
175
+ indeterminate={some_selected}
176
+ disabled={is_pending}
177
+ aria_label="Select all entries"
178
+ on_change={toggle_all}
179
+ />
180
+ ) : null}
181
+ <h2 className="m-0 text-[0.72rem] font-semibold uppercase tracking-[0.06em]">
182
+ {title}
183
+ </h2>
184
+ <span className="text-[0.8rem] text-muted">
185
+ {entries.length === 0
186
+ ? null
187
+ : entries.length === 1
188
+ ? '1 entry'
189
+ : `${entries.length} entries`}
190
+ </span>
191
+ </div>
192
+ <p className="m-0 font-mono text-[0.85rem] text-muted">
193
+ {format_duration(total_ms, duration_format)} total
194
+ </p>
195
+ </div>
196
+ )}
197
+ </header>
198
+ {entries.length === 0 ? (
199
+ <p className="m-0 text-muted">{empty_message}</p>
200
+ ) : (
201
+ <>
202
+ <ul className="m-0 flex list-none flex-col p-0">
203
+ {entries.map((entry) => {
204
+ const row_key = get_entry_row_key(entry)
205
+ const is_editing = editing_key === row_key
206
+ const is_selected = selected_keys.has(row_key)
207
+
208
+ if (is_editing) {
209
+ return (
210
+ <li
211
+ key={row_key}
212
+ className="block border-b border-panel-border py-2.5 last:border-b-0 compact:py-1.5"
213
+ >
214
+ <EntryEditForm
215
+ entry={entry}
216
+ is_pending={is_pending}
217
+ on_cancel={() => set_editing_key(null)}
218
+ on_save={(values) => {
219
+ on_edit(entry, values)
220
+ set_editing_key(null)
221
+ }}
222
+ />
223
+ </li>
224
+ )
225
+ }
226
+
227
+ return (
228
+ <li
229
+ key={row_key}
230
+ className={`group relative flex flex-col gap-0 border-b border-panel-border px-2 py-2.5 transition-colors duration-150 last:border-b-0 hover:bg-surface-hover compact:py-1.5 ${
231
+ is_selected
232
+ ? 'bg-accent-soft hover:bg-accent-soft'
233
+ : ''
234
+ }`}
235
+ >
236
+ <div className="flex w-full min-w-0 items-center gap-2.5 compact:gap-1.5">
237
+ <label
238
+ className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 has-disabled:cursor-not-allowed"
239
+ aria-label={`Select entry ${entry.description || 'Untitled entry'}`}
240
+ >
241
+ <Checkbox
242
+ nested
243
+ className={`shrink-0 pr-1 transition-opacity duration-150 compact:pr-0.5 ${
244
+ is_selected || has_selection
245
+ ? 'opacity-100'
246
+ : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100'
247
+ }`}
248
+ checked={is_selected}
249
+ disabled={is_pending}
250
+ on_change={() => toggle_entry(row_key)}
251
+ />
252
+ <div className="min-w-0 flex-1">
253
+ <p className="m-0 overflow-wrap-anywhere font-semibold leading-snug compact:text-[0.9rem] compact:leading-tight">
254
+ {entry.description || 'Untitled entry'}
255
+ </p>
256
+ <div className="mt-0.5 flex flex-wrap items-center gap-1 text-[0.8rem] text-muted compact:mt-px compact:gap-0.5 compact:text-[0.72rem]">
257
+ {show_sheet_name ? (
258
+ <>
259
+ <span>{entry.sheetName}</span>
260
+ <span>·</span>
261
+ </>
262
+ ) : null}
263
+ <span>#{entry.id}</span>
264
+ <span>·</span>
265
+ <span className="whitespace-nowrap">
266
+ {format_time(entry.start, time_format)}
267
+ {entry.end === null
268
+ ? ' → now'
269
+ : ` → ${format_time(entry.end, time_format)}`}
270
+ </span>
271
+ {entry.tags.length > 0 ? (
272
+ <ul className="m-0 flex list-none flex-wrap gap-1 p-0">
273
+ {entry.tags.map((tag) => (
274
+ <li key={tag} className={tag_item_class}>
275
+ {format_display_tag(tag)}
276
+ </li>
277
+ ))}
278
+ </ul>
279
+ ) : null}
280
+ </div>
281
+ </div>
282
+ </label>
283
+ <div className="flex shrink-0 flex-row items-center gap-2 max-[860px]:flex-wrap max-[860px]:justify-end compact:gap-1.5">
284
+ <p
285
+ className={`m-0 whitespace-nowrap text-right font-mono text-[0.9rem] text-muted compact:text-[0.8rem] ${
286
+ entry.isActive ? 'text-accent' : ''
287
+ }`}
288
+ >
289
+ {format_duration(entry.durationMs, duration_format)}
290
+ </p>
291
+ <EntryActionsMenu
292
+ current_sheet_name={entry.sheetName}
293
+ sheets={sheets}
294
+ is_pending={is_pending}
295
+ on_edit={() => set_editing_key(row_key)}
296
+ on_add_note={(text) => on_add_note(entry, text)}
297
+ on_resume={() => on_resume(entry)}
298
+ entry_is_active={entry.isActive}
299
+ on_move={(target_sheet_name) =>
300
+ on_move(entry, target_sheet_name)
301
+ }
302
+ on_delete={async () => {
303
+ const confirmed = confirm_destructive_actions
304
+ ? await confirm(
305
+ get_delete_entry_confirm_dialog(entry),
306
+ )
307
+ : true
308
+
309
+ if (confirmed) {
310
+ on_delete(entry)
311
+ }
312
+ }}
313
+ />
314
+ </div>
315
+ </div>
316
+ {entry.notes.length > 0 ? (
317
+ <div className="w-full pt-1 pl-[calc(0.85rem+0.5rem+0.35rem)] compact:pt-0.5 compact:pl-[calc(0.85rem+0.35rem+0.2rem)]">
318
+ <EntryNotesList
319
+ notes={entry.notes}
320
+ variant="inline"
321
+ is_pending={is_pending}
322
+ on_edit_note={(timestamp, text) =>
323
+ on_edit_note(entry, timestamp, text)
324
+ }
325
+ />
326
+ </div>
327
+ ) : null}
328
+ </li>
329
+ )
330
+ })}
331
+ </ul>
332
+ </>
333
+ )}
334
+ </section>
335
+ )
336
+ }
@@ -0,0 +1,211 @@
1
+ 'use client'
2
+
3
+ import { type MouseEvent, type ReactNode, useState } from 'react'
4
+
5
+ import { ChevronIcon } from '@/components/chevron-icon'
6
+ import { format_time } from '@/components/format_time'
7
+ import { NoteEditForm } from '@/components/note-edit-form'
8
+ import { PencilIcon } from '@/components/pencil-icon'
9
+ import { TrashIcon } from '@/components/trash-icon'
10
+ import { use_time_format } from '@/lib/use_time_format'
11
+ import { type SerializedNote } from '@/lib/types/tracker_state'
12
+
13
+ type EntryNotesListVariant = 'panel' | 'inline'
14
+
15
+ interface EntryNotesListProps {
16
+ notes: SerializedNote[]
17
+ variant?: EntryNotesListVariant
18
+ in_bar?: boolean
19
+ is_pending?: boolean
20
+ on_edit_note?: (timestamp: string, text: string) => void
21
+ on_delete_note?: (timestamp: string) => void
22
+ }
23
+
24
+ const edit_button_class =
25
+ 'inline-flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center rounded-[0.35rem] border-0 bg-transparent p-0 text-muted hover:bg-surface-hover hover:text-foreground disabled:cursor-not-allowed disabled:opacity-55'
26
+
27
+ /**
28
+ * Renders notes attached to a time sheet entry.
29
+ */
30
+ export function EntryNotesList({
31
+ notes,
32
+ variant = 'panel',
33
+ in_bar = false,
34
+ is_pending = false,
35
+ on_edit_note,
36
+ on_delete_note,
37
+ }: EntryNotesListProps) {
38
+ const time_format = use_time_format()
39
+ const [editing_timestamp, set_editing_timestamp] = useState<string | null>(
40
+ null,
41
+ )
42
+ const [is_expanded, set_is_expanded] = useState(false)
43
+
44
+ if (notes.length === 0) {
45
+ return null
46
+ }
47
+
48
+ const sorted_notes = [...notes].sort(
49
+ (left, right) =>
50
+ new Date(left.timestamp).getTime() - new Date(right.timestamp).getTime(),
51
+ )
52
+
53
+ const is_inline = variant === 'inline'
54
+ const is_list_visible = is_expanded || editing_timestamp !== null
55
+ const toggle_label = is_inline
56
+ ? `${notes.length} ${notes.length === 1 ? 'note' : 'notes'}`
57
+ : `Notes (${notes.length})`
58
+
59
+ const root_class = [
60
+ is_inline ? 'm-0 w-full p-0' : 'border-t border-accent-border pt-3.5',
61
+ in_bar && !is_inline
62
+ ? 'border-[color-mix(in_srgb,var(--accent-border)_65%,var(--panel-border))]'
63
+ : '',
64
+ ]
65
+ .filter(Boolean)
66
+ .join(' ')
67
+
68
+ const toggle_class = is_inline
69
+ ? 'inline-flex cursor-pointer items-center gap-1.5 border-0 bg-transparent p-0 font-inherit text-xs font-medium normal-case tracking-normal text-muted hover:text-foreground'
70
+ : 'inline-flex cursor-pointer items-center gap-1.5 border-0 bg-transparent px-0 py-0.5 font-inherit text-[0.72rem] font-semibold uppercase tracking-[0.04em] text-muted hover:text-foreground'
71
+
72
+ const list_class = is_inline
73
+ ? `m-0 flex list-none flex-col gap-1.5 overflow-visible p-0 compact:gap-0.5 ${is_list_visible ? 'mt-1.5' : 'hidden'}`
74
+ : `m-0 grid max-h-48 list-none grid-cols-2 gap-2 overflow-y-auto p-0 max-[860px]:grid-cols-1 ${is_list_visible ? 'mt-1.5' : 'hidden'}`
75
+
76
+ const item_class = is_inline
77
+ ? 'flex flex-col gap-0.5 rounded-sm border border-panel-border bg-ghost-bg px-2 py-1.5 compact:rounded-none compact:px-1.5 compact:py-1'
78
+ : 'flex flex-col gap-0.5 rounded-sm border border-panel-border bg-[color-mix(in_srgb,var(--panel)_55%,var(--background))] px-2.5 py-2'
79
+
80
+ const handle_save = (timestamp: string, text: string): void => {
81
+ on_edit_note?.(timestamp, text)
82
+ set_editing_timestamp(null)
83
+ }
84
+
85
+ const start_editing = (timestamp: string): void => {
86
+ set_is_expanded(true)
87
+ set_editing_timestamp(timestamp)
88
+ }
89
+
90
+ const handle_delete = (timestamp: string): void => {
91
+ if (editing_timestamp === timestamp) {
92
+ set_editing_timestamp(null)
93
+ }
94
+
95
+ on_delete_note?.(timestamp)
96
+ }
97
+
98
+ const render_note_actions = (note: SerializedNote): ReactNode => {
99
+ if (on_edit_note === undefined && on_delete_note === undefined) {
100
+ return null
101
+ }
102
+
103
+ return (
104
+ <div className="flex shrink-0 gap-0.5">
105
+ {on_edit_note !== undefined ? (
106
+ <button
107
+ type="button"
108
+ className={edit_button_class}
109
+ aria-label="Edit note"
110
+ title="Edit note"
111
+ disabled={is_pending}
112
+ onClick={(event) => {
113
+ event.stopPropagation()
114
+ start_editing(note.timestamp)
115
+ }}
116
+ >
117
+ <PencilIcon />
118
+ </button>
119
+ ) : null}
120
+ {on_delete_note !== undefined ? (
121
+ <button
122
+ type="button"
123
+ className={`${edit_button_class} hover:text-danger`}
124
+ aria-label="Delete note"
125
+ title="Delete note"
126
+ disabled={is_pending}
127
+ onClick={(event) => {
128
+ event.stopPropagation()
129
+ handle_delete(note.timestamp)
130
+ }}
131
+ >
132
+ <TrashIcon />
133
+ </button>
134
+ ) : null}
135
+ </div>
136
+ )
137
+ }
138
+
139
+ const handle_toggle = (event: MouseEvent<HTMLButtonElement>): void => {
140
+ event.stopPropagation()
141
+
142
+ if (editing_timestamp !== null) {
143
+ return
144
+ }
145
+
146
+ set_is_expanded((previous) => !previous)
147
+ }
148
+
149
+ return (
150
+ <section className={root_class} aria-label="Entry notes">
151
+ <button
152
+ type="button"
153
+ className={toggle_class}
154
+ aria-expanded={is_list_visible}
155
+ onClick={handle_toggle}
156
+ >
157
+ <ChevronIcon rotated={is_list_visible} />
158
+ <span>{toggle_label}</span>
159
+ </button>
160
+ <ul className={list_class}>
161
+ {sorted_notes.map((note, index) => {
162
+ const is_editing = editing_timestamp === note.timestamp
163
+
164
+ return (
165
+ <li key={`${note.timestamp}-${index}`} className={item_class}>
166
+ {is_editing ? (
167
+ <NoteEditForm
168
+ initial_text={note.text}
169
+ inline={is_inline}
170
+ is_pending={is_pending}
171
+ on_cancel={() => set_editing_timestamp(null)}
172
+ on_save={(text) => handle_save(note.timestamp, text)}
173
+ />
174
+ ) : is_inline ? (
175
+ <div className="flex w-full items-start justify-between gap-1.5">
176
+ <p className="m-0 flex min-w-0 flex-1 items-baseline gap-1.5 text-xs leading-snug text-foreground compact:text-[0.72rem]">
177
+ <time
178
+ className="shrink-0 font-mono text-[0.68rem] text-muted"
179
+ dateTime={note.timestamp}
180
+ >
181
+ {format_time(note.timestamp, time_format)}
182
+ </time>
183
+ <span className="min-w-0 overflow-wrap-anywhere whitespace-pre-wrap">
184
+ {note.text}
185
+ </span>
186
+ </p>
187
+ {render_note_actions(note)}
188
+ </div>
189
+ ) : (
190
+ <>
191
+ <div className="flex w-full items-start justify-between gap-1.5">
192
+ <time
193
+ className="font-mono text-[0.72rem] text-muted"
194
+ dateTime={note.timestamp}
195
+ >
196
+ {format_time(note.timestamp, time_format)}
197
+ </time>
198
+ {render_note_actions(note)}
199
+ </div>
200
+ <p className="m-0 overflow-wrap-anywhere text-[0.9rem] leading-snug whitespace-pre-wrap">
201
+ {note.text}
202
+ </p>
203
+ </>
204
+ )}
205
+ </li>
206
+ )
207
+ })}
208
+ </ul>
209
+ </section>
210
+ )
211
+ }
@@ -0,0 +1,99 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useSyncExternalStore } from 'react'
4
+
5
+ import { format_display_tag } from '@/lib/format_display_tag'
6
+ import { get_button_class_name } from '@/lib/get_button_class_name'
7
+ import {
8
+ get_sheet_tag_filter_server_snapshot,
9
+ get_sheet_tag_filter_snapshot,
10
+ } from '@/lib/get_sheet_tag_filter_snapshot'
11
+ import { prune_sheet_tag_filter } from '@/lib/prune_sheet_tag_filter'
12
+ import { set_sheet_tag_filter } from '@/lib/set_sheet_tag_filter'
13
+ import { subscribe_sheet_tag_filters } from '@/lib/subscribe_sheet_tag_filters'
14
+ import { tags_are_equal } from '@/lib/tags_are_equal'
15
+ import { toggle_sheet_tag_filter } from '@/lib/toggle_sheet_tag_filter'
16
+ import { use_tag_filter_mode } from '@/lib/use_tag_filter_mode'
17
+ import { type TagStat } from '@/lib/types/tag_management'
18
+
19
+ interface EntryTagFilterProps {
20
+ sheet_name: string
21
+ sheet_tags: TagStat[]
22
+ }
23
+
24
+ /**
25
+ * Toggle filters for showing only entries that match selected tags.
26
+ */
27
+ export function EntryTagFilter({ sheet_name, sheet_tags }: EntryTagFilterProps) {
28
+ const tag_filter_mode = use_tag_filter_mode()
29
+ const filter_tags = useSyncExternalStore(
30
+ subscribe_sheet_tag_filters,
31
+ () => get_sheet_tag_filter_snapshot(sheet_name),
32
+ get_sheet_tag_filter_server_snapshot,
33
+ )
34
+
35
+ useEffect(() => {
36
+ prune_sheet_tag_filter(
37
+ sheet_name,
38
+ sheet_tags.map((tag) => tag.name),
39
+ )
40
+ }, [sheet_name, sheet_tags])
41
+
42
+ if (sheet_tags.length === 0) {
43
+ return null
44
+ }
45
+
46
+ const has_filter = filter_tags.length > 0
47
+
48
+ return (
49
+ <fieldset className="m-0 rounded-lg border border-panel-border bg-panel p-3.5 shadow-sm compact:p-3">
50
+ <legend className="px-0.5 text-[0.72rem] font-semibold uppercase tracking-[0.06em] text-muted">
51
+ Filter by tag
52
+ </legend>
53
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
54
+ <p className="m-0 shrink-0 text-[0.8rem] leading-snug text-muted">
55
+ {tag_filter_mode === 'any'
56
+ ? 'Show entries that include any selected tag.'
57
+ : 'Show entries that include every selected tag.'}
58
+ </p>
59
+ <div
60
+ className="flex min-w-0 flex-1 flex-wrap justify-end gap-1.5"
61
+ role="group"
62
+ aria-label="Filter by tag"
63
+ >
64
+ {sheet_tags.map((tag) => {
65
+ const is_selected = filter_tags.some((filter_tag) =>
66
+ tags_are_equal(filter_tag, tag.name),
67
+ )
68
+
69
+ return (
70
+ <button
71
+ key={tag.name}
72
+ type="button"
73
+ className={`${get_button_class_name('ghost', 'small')} ${
74
+ is_selected
75
+ ? 'border-accent-border bg-accent-soft text-foreground'
76
+ : ''
77
+ }`}
78
+ aria-pressed={is_selected}
79
+ onClick={() => toggle_sheet_tag_filter(sheet_name, tag.name)}
80
+ >
81
+ {format_display_tag(tag.name)}
82
+ <span className="ml-1 font-normal text-muted">({tag.entryCount})</span>
83
+ </button>
84
+ )
85
+ })}
86
+ </div>
87
+ </div>
88
+ {has_filter ? (
89
+ <button
90
+ type="button"
91
+ className={`${get_button_class_name('ghost', 'small')} mt-2.5`}
92
+ onClick={() => set_sheet_tag_filter(sheet_name, [])}
93
+ >
94
+ Clear filter
95
+ </button>
96
+ ) : null}
97
+ </fieldset>
98
+ )
99
+ }
@@ -0,0 +1,8 @@
1
+ import { format } from "date-fns";
2
+
3
+ /**
4
+ * Formats an ISO timestamp as a readable hint for edit forms.
5
+ */
6
+ export function format_datetime_hint(iso: string): string {
7
+ return format(new Date(iso), "MMM d, h:mm a");
8
+ }