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,56 @@
1
+ import { get_sheet } from "@/lib/get_sheet";
2
+ import { has_string_value } from "@/lib/has_string_value";
3
+ import { parse_natural_language_date } from "@/lib/parse_natural_language_date";
4
+ import { read_db } from "@/lib/read_db";
5
+ import { validate_entry_times } from "@/lib/validate_entry_times";
6
+ import { write_db } from "@/lib/write_db";
7
+ import { type TimeSheetEntry } from "@/lib/types";
8
+
9
+ export interface EditEntryArgs {
10
+ sheet_name: string;
11
+ entry_id: number;
12
+ start?: string;
13
+ end?: string;
14
+ description?: string;
15
+ }
16
+
17
+ /**
18
+ * Updates an entry's description and/or start and end times.
19
+ */
20
+ export async function edit_entry(args: EditEntryArgs): Promise<TimeSheetEntry> {
21
+ const { description, end, entry_id, sheet_name, start } = args;
22
+ const db = await read_db();
23
+ const sheet = get_sheet(db, sheet_name);
24
+ const entry = sheet.entries.find(({ id }) => id === entry_id);
25
+
26
+ if (entry === undefined) {
27
+ throw new Error(`Entry ${entry_id} not found in sheet ${sheet_name}`);
28
+ }
29
+
30
+ let did_update = false;
31
+
32
+ if (has_string_value(description)) {
33
+ entry.description = description.trim();
34
+ did_update = true;
35
+ }
36
+
37
+ if (has_string_value(start)) {
38
+ entry.start = parse_natural_language_date(start);
39
+ did_update = true;
40
+ }
41
+
42
+ if (has_string_value(end)) {
43
+ entry.end = parse_natural_language_date(end);
44
+ did_update = true;
45
+ }
46
+
47
+ if (!did_update) {
48
+ throw new Error("No changes provided");
49
+ }
50
+
51
+ validate_entry_times(entry.start, entry.end);
52
+
53
+ await write_db(db);
54
+
55
+ return entry;
56
+ }
@@ -0,0 +1,49 @@
1
+ import { get_sheet } from "@/lib/get_sheet";
2
+ import { read_db } from "@/lib/read_db";
3
+ import { write_db } from "@/lib/write_db";
4
+
5
+ export interface EditNoteOnEntryArgs {
6
+ text: string;
7
+ note_timestamp: string;
8
+ sheet_name: string;
9
+ entry_id: number;
10
+ }
11
+
12
+ /**
13
+ * Updates the text of an existing note on a time sheet entry.
14
+ */
15
+ export async function edit_note_on_entry(
16
+ args: EditNoteOnEntryArgs,
17
+ ): Promise<void> {
18
+ const { entry_id, note_timestamp, sheet_name, text } = args;
19
+ const trimmed = text.trim();
20
+
21
+ if (trimmed.length === 0) {
22
+ throw new Error("Note text is required");
23
+ }
24
+
25
+ const db = await read_db();
26
+ const sheet = get_sheet(db, sheet_name);
27
+ const entry = sheet.entries.find(({ id }) => id === entry_id);
28
+
29
+ if (entry === undefined) {
30
+ throw new Error(`Entry ${entry_id} not found in sheet ${sheet_name}`);
31
+ }
32
+
33
+ const target_ms = new Date(note_timestamp).getTime();
34
+
35
+ if (Number.isNaN(target_ms)) {
36
+ throw new Error("Invalid note timestamp");
37
+ }
38
+
39
+ const note = entry.notes.find(
40
+ ({ timestamp }) => timestamp.getTime() === target_ms,
41
+ );
42
+
43
+ if (note === undefined) {
44
+ throw new Error("Note not found on entry");
45
+ }
46
+
47
+ note.text = trimmed;
48
+ await write_db(db);
49
+ }
@@ -0,0 +1,22 @@
1
+ import { promises as fs } from "node:fs";
2
+
3
+ /**
4
+ * Ensures a directory exists, creating it recursively when missing.
5
+ */
6
+ export async function ensure_dir_exists(dir_path: string): Promise<void> {
7
+ try {
8
+ await fs.mkdir(dir_path, { recursive: true });
9
+ } catch (err: unknown) {
10
+ const code =
11
+ err !== null &&
12
+ typeof err === "object" &&
13
+ "code" in err &&
14
+ typeof err.code === "string"
15
+ ? err.code
16
+ : null;
17
+
18
+ if (code !== "EEXIST") {
19
+ throw err;
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,26 @@
1
+ import { tags_are_equal } from '@/lib/tags_are_equal'
2
+ import { type TagFilterMode } from '@/lib/types/ui_preferences'
3
+ import { type SerializedEntry } from '@/lib/types/tracker_state'
4
+
5
+ /**
6
+ * Returns whether an entry matches the selected tag filter tags.
7
+ */
8
+ export function entry_matches_tag_filter(
9
+ entry: SerializedEntry,
10
+ filter_tags: readonly string[],
11
+ mode: TagFilterMode = 'all',
12
+ ): boolean {
13
+ if (filter_tags.length === 0) {
14
+ return true
15
+ }
16
+
17
+ if (mode === 'any') {
18
+ return filter_tags.some((filter_tag) =>
19
+ entry.tags.some((entry_tag) => tags_are_equal(entry_tag, filter_tag)),
20
+ )
21
+ }
22
+
23
+ return filter_tags.every((filter_tag) =>
24
+ entry.tags.some((entry_tag) => tags_are_equal(entry_tag, filter_tag)),
25
+ )
26
+ }
@@ -0,0 +1,15 @@
1
+ import { type TrackerState } from "@/lib/types/tracker_state";
2
+
3
+ /**
4
+ * Loads tracker state from the API.
5
+ */
6
+ export async function fetch_tracker_state(): Promise<TrackerState> {
7
+ const response = await fetch("/api/state", { cache: "no-store" });
8
+
9
+ if (!response.ok) {
10
+ const body = (await response.json()) as { error?: string };
11
+ throw new Error(body.error ?? "Failed to load tracker state");
12
+ }
13
+
14
+ return (await response.json()) as TrackerState;
15
+ }
@@ -0,0 +1,20 @@
1
+ import { entry_matches_tag_filter } from '@/lib/entry_matches_tag_filter'
2
+ import { type TagFilterMode } from '@/lib/types/ui_preferences'
3
+ import { type SerializedEntry } from '@/lib/types/tracker_state'
4
+
5
+ /**
6
+ * Returns entries that match the selected filter tags.
7
+ */
8
+ export function filter_entries_by_tags(
9
+ entries: SerializedEntry[],
10
+ filter_tags: readonly string[],
11
+ mode: TagFilterMode = 'all',
12
+ ): SerializedEntry[] {
13
+ if (filter_tags.length === 0) {
14
+ return entries
15
+ }
16
+
17
+ return entries.filter((entry) =>
18
+ entry_matches_tag_filter(entry, filter_tags, mode),
19
+ )
20
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Returns known tags whose name matches the partial query after @.
3
+ */
4
+ export function filter_known_tags(
5
+ known_tags: string[],
6
+ query: string,
7
+ limit: number = 10,
8
+ ): string[] {
9
+ const normalized_query = query.toLowerCase()
10
+
11
+ return known_tags
12
+ .filter((tag) => {
13
+ const name = tag.replace(/^@/, '').toLowerCase()
14
+
15
+ return (
16
+ normalized_query.length === 0 || name.startsWith(normalized_query)
17
+ )
18
+ })
19
+ .slice(0, limit)
20
+ }
@@ -0,0 +1,28 @@
1
+ import { serialize_entry } from '@/lib/serialize_entry'
2
+ import { type SerializedEntry } from '@/lib/types/tracker_state'
3
+ import { type TimeTrackerDB } from '@/lib/types'
4
+
5
+ /**
6
+ * Returns every running entry across all sheets, in sheet order.
7
+ */
8
+ export function find_all_serialized_active_entries(
9
+ db: TimeTrackerDB,
10
+ ): SerializedEntry[] {
11
+ const results: SerializedEntry[] = []
12
+
13
+ for (const sheet of db.sheets) {
14
+ const { activeEntryID, entries, name } = sheet
15
+
16
+ if (activeEntryID === null) {
17
+ continue
18
+ }
19
+
20
+ const entry = entries.find(({ id }) => id === activeEntryID)
21
+
22
+ if (entry !== undefined && entry.end === null) {
23
+ results.push(serialize_entry(entry, name, true))
24
+ }
25
+ }
26
+
27
+ return results
28
+ }
@@ -0,0 +1,12 @@
1
+ import { find_all_serialized_active_entries } from '@/lib/find_all_serialized_active_entries'
2
+ import { type SerializedEntry } from '@/lib/types/tracker_state'
3
+ import { type TimeTrackerDB } from '@/lib/types'
4
+
5
+ /**
6
+ * Returns the first running entry from any sheet, if one exists.
7
+ */
8
+ export function find_serialized_active_entry(
9
+ db: TimeTrackerDB,
10
+ ): SerializedEntry | null {
11
+ return find_all_serialized_active_entries(db)[0] ?? null
12
+ }
@@ -0,0 +1,31 @@
1
+ import { serialize_entry } from '@/lib/serialize_entry'
2
+ import { type SerializedEntry } from '@/lib/types/tracker_state'
3
+ import { type TimeTrackerDB } from '@/lib/types'
4
+
5
+ /**
6
+ * Returns the running entry on a specific sheet, if one exists.
7
+ */
8
+ export function find_serialized_active_entry_for_sheet(
9
+ db: TimeTrackerDB,
10
+ sheet_name: string,
11
+ ): SerializedEntry | null {
12
+ const sheet = db.sheets.find(({ name }) => name === sheet_name)
13
+
14
+ if (sheet === undefined) {
15
+ return null
16
+ }
17
+
18
+ const { activeEntryID, entries, name } = sheet
19
+
20
+ if (activeEntryID === null) {
21
+ return null
22
+ }
23
+
24
+ const entry = entries.find(({ id }) => id === activeEntryID)
25
+
26
+ if (entry === undefined) {
27
+ return null
28
+ }
29
+
30
+ return serialize_entry(entry, name, entry.end === null)
31
+ }
@@ -0,0 +1,16 @@
1
+ import { type TimeSheet, type TimeTrackerDB } from '@/lib/types'
2
+
3
+ /**
4
+ * Returns the sheet that has a running entry, if one exists.
5
+ */
6
+ export function find_sheet_with_active_entry(
7
+ db: TimeTrackerDB,
8
+ ): TimeSheet | null {
9
+ for (const sheet of db.sheets) {
10
+ if (sheet.activeEntryID !== null) {
11
+ return sheet
12
+ }
13
+ }
14
+
15
+ return null
16
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Formats a tag for display, ensuring it is prefixed with @.
3
+ */
4
+ export function format_display_tag(tag: string): string {
5
+ return tag.startsWith("@") ? tag : `@${tag}`;
6
+ }
@@ -0,0 +1,45 @@
1
+ import humanizeDuration from 'humanize-duration'
2
+
3
+ import { type DurationFormat } from '@/lib/types/ui_preferences'
4
+
5
+ const pad = (value: number): string => value.toString().padStart(2, '0')
6
+
7
+ const format_clock = (duration_ms: number): string => {
8
+ const sign = duration_ms < 0 ? '-' : ''
9
+ const total_seconds = Math.round(Math.abs(duration_ms) / 1000)
10
+ const hours = Math.floor(total_seconds / 3600)
11
+ const minutes = Math.floor((total_seconds % 3600) / 60)
12
+ const seconds = total_seconds % 60
13
+
14
+ return `${sign}${pad(hours)}:${pad(minutes)}:${pad(seconds)}`
15
+ }
16
+
17
+ const format_decimal = (duration_ms: number): string => {
18
+ const hours = duration_ms / 3_600_000
19
+
20
+ return `${hours.toFixed(2)}h`
21
+ }
22
+
23
+ /**
24
+ * Formats a duration in milliseconds using the preferred format.
25
+ */
26
+ export function format_duration(
27
+ duration_ms: number,
28
+ duration_format: DurationFormat = 'humanized',
29
+ show_seconds = false,
30
+ ): string {
31
+ if (duration_format === 'clock') {
32
+ return format_clock(duration_ms)
33
+ }
34
+
35
+ if (duration_format === 'decimal') {
36
+ return format_decimal(duration_ms)
37
+ }
38
+
39
+ return humanizeDuration(duration_ms, {
40
+ largest: show_seconds ? 3 : 2,
41
+ round: !show_seconds,
42
+ spacer: ' ',
43
+ delimiter: ' ',
44
+ })
45
+ }
package/lib/gen_db.ts ADDED
@@ -0,0 +1,43 @@
1
+ import { DB_VERSION, DEFAULT_SHEET_NAME } from "@/lib/config";
2
+ import {
3
+ type TimeSheet,
4
+ type TimeSheetEntry,
5
+ type TimeTrackerDB,
6
+ } from "@/lib/types";
7
+
8
+ /**
9
+ * Creates an empty sheet with optional entries and active entry.
10
+ */
11
+ export function gen_sheet(
12
+ name: string,
13
+ entries: TimeSheetEntry[] = [],
14
+ active_entry_id: number | null = null,
15
+ ): TimeSheet {
16
+ if (name.length === 0) {
17
+ throw new Error("New sheet name must not be empty");
18
+ }
19
+
20
+ if (
21
+ active_entry_id !== null &&
22
+ entries.find(({ id }) => id === active_entry_id) === undefined
23
+ ) {
24
+ throw new Error("New sheet active entry does not exist");
25
+ }
26
+
27
+ return {
28
+ name,
29
+ entries,
30
+ activeEntryID: active_entry_id,
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Creates a fresh time tracker database with the default sheet.
36
+ */
37
+ export function gen_db(): TimeTrackerDB {
38
+ return {
39
+ version: DB_VERSION,
40
+ sheets: [gen_sheet(DEFAULT_SHEET_NAME)],
41
+ activeSheetName: DEFAULT_SHEET_NAME,
42
+ };
43
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Returns Tailwind classes for the active entry panel container.
3
+ */
4
+ export function get_active_panel_class_name(
5
+ in_bar: boolean,
6
+ is_editing: boolean,
7
+ ): string {
8
+ const base =
9
+ 'flex flex-col gap-4 rounded-lg border border-accent-border p-[1.1rem] shadow-sm'
10
+
11
+ if (!in_bar) {
12
+ return `${base} bg-[image:var(--active-panel-bg)]`
13
+ }
14
+
15
+ if (is_editing) {
16
+ return `${base} rounded-md border border-accent-border bg-[color-mix(in_srgb,var(--panel)_70%,var(--background))] p-3.5`
17
+ }
18
+
19
+ return 'flex flex-col gap-3.5 border-0 bg-transparent p-0 shadow-none'
20
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Returns mean duration per entry, or zero when there are no entries.
3
+ */
4
+ export function get_average_entry_ms(
5
+ total_ms: number,
6
+ entry_count: number,
7
+ ): number {
8
+ if (entry_count === 0) {
9
+ return 0
10
+ }
11
+
12
+ return total_ms / entry_count
13
+ }
@@ -0,0 +1,24 @@
1
+ export type ButtonVariant = 'primary' | 'ghost' | 'danger'
2
+ export type ButtonSize = 'default' | 'small'
3
+
4
+ /**
5
+ * Returns Tailwind classes for themed buttons.
6
+ */
7
+ export function get_button_class_name(
8
+ variant: ButtonVariant = 'ghost',
9
+ size: ButtonSize = 'default',
10
+ ): string {
11
+ const base =
12
+ 'cursor-pointer rounded-[0.65rem] border border-transparent font-inherit font-semibold disabled:cursor-not-allowed disabled:opacity-55'
13
+ const sizes: Record<ButtonSize, string> = {
14
+ default: 'px-3.5 py-2.5',
15
+ small: 'px-2.5 py-1.5 text-xs',
16
+ }
17
+ const variants: Record<ButtonVariant, string> = {
18
+ primary: 'bg-accent text-accent-text-on',
19
+ ghost: 'border-panel-border bg-ghost-bg text-inherit',
20
+ danger: 'border-danger-border bg-danger-soft text-danger',
21
+ }
22
+
23
+ return `${base} ${sizes[size]} ${variants[variant]}`
24
+ }
@@ -0,0 +1,19 @@
1
+ import { type ConfirmDialogOptions } from '@/lib/types/confirm_dialog'
2
+
3
+ /**
4
+ * Builds confirm dialog options for checking out of an active timer.
5
+ */
6
+ export function get_check_out_confirm_dialog(at?: string): ConfirmDialogOptions {
7
+ const trimmed_at = at?.trim() ?? ''
8
+ const message =
9
+ trimmed_at.length > 0
10
+ ? `Stop the timer and check out at "${trimmed_at}"?`
11
+ : 'Stop the active timer and save this entry?'
12
+
13
+ return {
14
+ title: 'Check out?',
15
+ message,
16
+ confirmLabel: 'Check out',
17
+ variant: 'danger',
18
+ }
19
+ }
@@ -0,0 +1,18 @@
1
+ import { type TimeSheetEntry } from '@/lib/types'
2
+
3
+ /**
4
+ * Returns entry duration in milliseconds clipped to an inclusive time range.
5
+ */
6
+ export function get_clipped_entry_duration_ms(
7
+ entry: TimeSheetEntry,
8
+ range_start_ms: number,
9
+ range_end_ms: number,
10
+ now: number = Date.now(),
11
+ ): number {
12
+ const entry_start_ms = +entry.start
13
+ const entry_end_ms = entry.end === null ? now : +entry.end
14
+ const clipped_start_ms = Math.max(entry_start_ms, range_start_ms)
15
+ const clipped_end_ms = Math.min(entry_end_ms, range_end_ms)
16
+
17
+ return Math.max(0, clipped_end_ms - clipped_start_ms)
18
+ }
@@ -0,0 +1,15 @@
1
+ import { read_document_compact_lists } from "@/lib/read_document_compact_lists";
2
+
3
+ /**
4
+ * Returns the compact lists snapshot from the document (client-only).
5
+ */
6
+ export function get_compact_lists_snapshot(): boolean {
7
+ return read_document_compact_lists();
8
+ }
9
+
10
+ /**
11
+ * Returns the compact lists snapshot used during server rendering.
12
+ */
13
+ export function get_compact_lists_server_snapshot(): boolean {
14
+ return false;
15
+ }
@@ -0,0 +1,31 @@
1
+ import { endOfDay, parse, startOfDay } from 'date-fns'
2
+
3
+ import { type PeriodRangeMs } from '@/lib/get_period_range_ms'
4
+
5
+ /**
6
+ * Converts date input values into inclusive millisecond bounds, or null when unset.
7
+ */
8
+ export function get_date_range_ms_from_inputs(
9
+ from_date: string,
10
+ to_date: string,
11
+ ): PeriodRangeMs | null {
12
+ if (from_date.length === 0 || to_date.length === 0) {
13
+ return null
14
+ }
15
+
16
+ const range_start = parse(from_date, 'yyyy-MM-dd', new Date())
17
+ const range_end = parse(to_date, 'yyyy-MM-dd', new Date())
18
+
19
+ if (Number.isNaN(+range_start) || Number.isNaN(+range_end)) {
20
+ return null
21
+ }
22
+
23
+ if (+range_start > +range_end) {
24
+ return null
25
+ }
26
+
27
+ return {
28
+ startMs: +startOfDay(range_start),
29
+ endMs: +endOfDay(range_end),
30
+ }
31
+ }
@@ -0,0 +1,21 @@
1
+ import { type ConfirmDialogOptions } from '@/lib/types/confirm_dialog'
2
+ import { type SerializedEntry } from '@/lib/types/tracker_state'
3
+
4
+ /**
5
+ * Builds confirm dialog options for deleting multiple time sheet entries.
6
+ */
7
+ export function get_delete_entries_confirm_dialog(
8
+ entries: SerializedEntry[],
9
+ ): ConfirmDialogOptions {
10
+ const count = entries.length
11
+ const has_active = entries.some((entry) => entry.isActive)
12
+ const active_note = has_active ? ' This will stop the active timer.' : ''
13
+ const label = count === 1 ? '1 entry' : `${count} entries`
14
+
15
+ return {
16
+ title: count === 1 ? 'Delete entry?' : 'Delete entries?',
17
+ message: `Permanently delete ${label}?${active_note}`,
18
+ confirmLabel: 'Delete',
19
+ variant: 'danger',
20
+ }
21
+ }
@@ -0,0 +1,19 @@
1
+ import { type ConfirmDialogOptions } from '@/lib/types/confirm_dialog'
2
+ import { type SerializedEntry } from '@/lib/types/tracker_state'
3
+
4
+ /**
5
+ * Builds confirm dialog options for deleting a time sheet entry.
6
+ */
7
+ export function get_delete_entry_confirm_dialog(
8
+ entry: SerializedEntry,
9
+ ): ConfirmDialogOptions {
10
+ const description = entry.description.trim() || 'Untitled entry'
11
+ const active_note = entry.isActive ? ' This will stop the active timer.' : ''
12
+
13
+ return {
14
+ title: 'Delete entry?',
15
+ message: `Delete "${description}" on sheet "${entry.sheetName}"?${active_note}`,
16
+ confirmLabel: 'Delete',
17
+ variant: 'danger',
18
+ }
19
+ }
@@ -0,0 +1,21 @@
1
+ import { type ConfirmDialogOptions } from '@/lib/types/confirm_dialog'
2
+
3
+ /**
4
+ * Builds confirm dialog options for deleting a note on an entry.
5
+ */
6
+ export function get_delete_note_confirm_dialog(
7
+ note_text: string,
8
+ ): ConfirmDialogOptions {
9
+ const trimmed = note_text.trim()
10
+ const preview =
11
+ trimmed.length > 80 ? `${trimmed.slice(0, 80)}…` : trimmed
12
+ const message =
13
+ preview.length > 0 ? `Remove this note? "${preview}"` : 'Remove this note?'
14
+
15
+ return {
16
+ title: 'Delete note?',
17
+ message,
18
+ confirmLabel: 'Delete',
19
+ variant: 'danger',
20
+ }
21
+ }
@@ -0,0 +1,25 @@
1
+ import { type ConfirmDialogOptions } from '@/lib/types/confirm_dialog'
2
+
3
+ /**
4
+ * Builds confirm dialog options for deleting a time sheet.
5
+ */
6
+ export function get_delete_sheet_confirm_dialog(
7
+ sheet_name: string,
8
+ entry_count: number,
9
+ has_active_entry: boolean,
10
+ ): ConfirmDialogOptions {
11
+ const entry_note =
12
+ entry_count === 0
13
+ ? ''
14
+ : ` This will delete ${entry_count} ${
15
+ entry_count === 1 ? 'entry' : 'entries'
16
+ }.`
17
+ const active_note = has_active_entry ? ' This will stop the active timer.' : ''
18
+
19
+ return {
20
+ title: 'Delete sheet?',
21
+ message: `Delete "${sheet_name}"?${entry_note}${active_note}`,
22
+ confirmLabel: 'Delete sheet',
23
+ variant: 'danger',
24
+ }
25
+ }
@@ -0,0 +1,14 @@
1
+ import { type TimeSheetEntry } from "@/lib/types";
2
+
3
+ /**
4
+ * Returns entry duration in milliseconds, using now when still active.
5
+ */
6
+ export function get_entry_duration_ms(
7
+ entry: TimeSheetEntry,
8
+ now: number = Date.now(),
9
+ ): number {
10
+ const { end, start } = entry;
11
+ const effective_end = end === null ? now : +end;
12
+
13
+ return Math.max(0, effective_end - +start);
14
+ }
@@ -0,0 +1,8 @@
1
+ import { type SerializedEntry } from "@/lib/types/tracker_state";
2
+
3
+ /**
4
+ * Returns a stable row key for an entry in list UIs.
5
+ */
6
+ export function get_entry_row_key(entry: SerializedEntry): string {
7
+ return `${entry.sheetName}-${entry.id}`;
8
+ }