@nightkatana/kronosys-app 1.0.0-beta.0

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 (179) hide show
  1. package/README.md +81 -0
  2. package/app/api/action/route.ts +16 -0
  3. package/app/api/backup/route.ts +84 -0
  4. package/app/api/health/route.ts +22 -0
  5. package/app/api/state/route.ts +27 -0
  6. package/app/apple-icon.png +0 -0
  7. package/app/changelog/page.tsx +122 -0
  8. package/app/globals.css +210 -0
  9. package/app/guide/layout.tsx +11 -0
  10. package/app/guide/page.tsx +278 -0
  11. package/app/icon.png +0 -0
  12. package/app/layout.tsx +77 -0
  13. package/app/licenses/layout.tsx +11 -0
  14. package/app/licenses/page.tsx +246 -0
  15. package/app/manifest.ts +32 -0
  16. package/app/page.tsx +1610 -0
  17. package/app/reporting/page.tsx +2943 -0
  18. package/app/settings/layout.tsx +10 -0
  19. package/app/settings/page.tsx +3518 -0
  20. package/bin/kronosys.mjs +46 -0
  21. package/components/KronosysPackageVersionProvider.tsx +19 -0
  22. package/components/KronosysPayloadProvider.tsx +109 -0
  23. package/components/PwaRegister.tsx +25 -0
  24. package/components/SiteLegalFooter.tsx +21 -0
  25. package/components/ThemeProvider.tsx +78 -0
  26. package/components/dashboard/AppShellLiveSessionDrawer.tsx +394 -0
  27. package/components/dashboard/AppShellRouteNav.tsx +131 -0
  28. package/components/dashboard/AppVersionStamp.tsx +16 -0
  29. package/components/dashboard/DashboardCollapsibleSection.tsx +57 -0
  30. package/components/dashboard/DashboardColumnHintsBanner.tsx +159 -0
  31. package/components/dashboard/DashboardCommandCenter.tsx +470 -0
  32. package/components/dashboard/DashboardLangGateModal.tsx +118 -0
  33. package/components/dashboard/DashboardLoadingOverlay.tsx +42 -0
  34. package/components/dashboard/DashboardSimpleModal.tsx +337 -0
  35. package/components/dashboard/DashboardSuspenseFallback.tsx +52 -0
  36. package/components/dashboard/DashboardToastProvider.tsx +64 -0
  37. package/components/dashboard/DashboardTour.tsx +435 -0
  38. package/components/dashboard/DeferredDescriptionPopoverWrap.tsx +39 -0
  39. package/components/dashboard/DeleteSessionModal.tsx +130 -0
  40. package/components/dashboard/DescriptionTooltipPortaled.tsx +31 -0
  41. package/components/dashboard/GitIdentityQuickSetupModal.tsx +211 -0
  42. package/components/dashboard/HeaderIntegrationBadges.tsx +69 -0
  43. package/components/dashboard/InlineMetricHelpTrigger.tsx +102 -0
  44. package/components/dashboard/IssuePickerModal.tsx +168 -0
  45. package/components/dashboard/KronoFocusPanel.tsx +834 -0
  46. package/components/dashboard/KronosysDatetimePopoverField.tsx +357 -0
  47. package/components/dashboard/KronosysTimePopoverField.tsx +233 -0
  48. package/components/dashboard/LanguageMenu.tsx +123 -0
  49. package/components/dashboard/MongoMirrorSyncLine.tsx +57 -0
  50. package/components/dashboard/NewSessionScopeModal.tsx +410 -0
  51. package/components/dashboard/PageRefreshButton.tsx +130 -0
  52. package/components/dashboard/PlainHelpPopover.tsx +97 -0
  53. package/components/dashboard/ReportingPageToc.tsx +68 -0
  54. package/components/dashboard/ReportingTour.tsx +342 -0
  55. package/components/dashboard/SavedProjectPicker.tsx +92 -0
  56. package/components/dashboard/SavedTagPicker.tsx +115 -0
  57. package/components/dashboard/ScrollToTopFab.tsx +41 -0
  58. package/components/dashboard/SelectedSessionSidebarBlock.tsx +630 -0
  59. package/components/dashboard/SessionEndReasonEditor.tsx +114 -0
  60. package/components/dashboard/SessionListPanel.tsx +320 -0
  61. package/components/dashboard/SessionLocMetricsSection.tsx +128 -0
  62. package/components/dashboard/SettingsTagsProjectsSection.tsx +993 -0
  63. package/components/dashboard/SettingsTour.tsx +332 -0
  64. package/components/dashboard/TagPills.tsx +149 -0
  65. package/components/dashboard/TagsHelpTrigger.tsx +84 -0
  66. package/components/dashboard/TaskFocusPanel.tsx +1261 -0
  67. package/components/dashboard/TaskSessionLiveCard.tsx +832 -0
  68. package/components/dashboard/TaskSubtasksBlock.tsx +748 -0
  69. package/components/dashboard/ThemeToggle.test.tsx +26 -0
  70. package/components/dashboard/ThemeToggle.tsx +36 -0
  71. package/components/dashboard/UserGuideBodyText.tsx +62 -0
  72. package/components/dashboard/WorkspaceGitRepoCard.tsx +191 -0
  73. package/components/dashboard/taskFieldStyles.ts +139 -0
  74. package/components/dashboard/useAnchoredFloatingPortalStyle.ts +71 -0
  75. package/components/dashboard/useDescriptionPopoverAfterMs.ts +220 -0
  76. package/components/dashboard/useKronoFocusLiveSeconds.ts +36 -0
  77. package/components/dashboard/useSmoothStopwatchMs.ts +25 -0
  78. package/lib/appShellHeaderClasses.ts +12 -0
  79. package/lib/backupCsvExport.test.ts +149 -0
  80. package/lib/backupCsvExport.ts +392 -0
  81. package/lib/changelogCopy.ts +34 -0
  82. package/lib/concurrentTaskStartPreference.ts +29 -0
  83. package/lib/dashboardClockFormat.ts +13 -0
  84. package/lib/dashboardColumnChrome.ts +3 -0
  85. package/lib/dashboardColumnHintsStorage.ts +57 -0
  86. package/lib/dashboardCopy.ts +1831 -0
  87. package/lib/dashboardDetachedUrlHintStorage.ts +24 -0
  88. package/lib/dashboardGitIdentityBannerStorage.ts +36 -0
  89. package/lib/dashboardLangStorage.ts +72 -0
  90. package/lib/dashboardQuickSearch.ts +476 -0
  91. package/lib/dashboardQuickSearchQuery.test.ts +63 -0
  92. package/lib/dashboardQuickSearchQuery.ts +179 -0
  93. package/lib/dashboardSessionNav.ts +33 -0
  94. package/lib/dashboardShortcuts.ts +268 -0
  95. package/lib/dashboardTimeZone.ts +91 -0
  96. package/lib/dashboardTourStorage.ts +68 -0
  97. package/lib/dataDir.test.ts +87 -0
  98. package/lib/dataDir.ts +83 -0
  99. package/lib/devDataPreferenceFile.ts +55 -0
  100. package/lib/devDataRuntimeInfo.ts +34 -0
  101. package/lib/formatIsoShort.test.ts +46 -0
  102. package/lib/formatIsoShort.ts +29 -0
  103. package/lib/generatedUserChangelog.ts +34 -0
  104. package/lib/gitlabIssueSearch.ts +8 -0
  105. package/lib/kronoFocusDurationHistory.ts +71 -0
  106. package/lib/kronoFocusRhythm.test.ts +130 -0
  107. package/lib/kronoFocusRhythm.ts +46 -0
  108. package/lib/kronoFocusTimerUrgency.test.ts +74 -0
  109. package/lib/kronoFocusTimerUrgency.ts +24 -0
  110. package/lib/kronosysApi.ts +143 -0
  111. package/lib/legacyEditorPayloadKeys.ts +52 -0
  112. package/lib/legacyKronoFocusStorageKeys.test.ts +29 -0
  113. package/lib/legacyKronoFocusStorageKeys.ts +32 -0
  114. package/lib/licensesCopy.ts +128 -0
  115. package/lib/openPlainTextInNewTab.ts +49 -0
  116. package/lib/readKronosysPackageVersion.ts +10 -0
  117. package/lib/reportingAggregate.test.ts +325 -0
  118. package/lib/reportingAggregate.ts +819 -0
  119. package/lib/reportingDatePresets.ts +41 -0
  120. package/lib/reportingMetricHelp.ts +430 -0
  121. package/lib/reportingNonFinalIndicators.test.ts +157 -0
  122. package/lib/reportingNonFinalIndicators.ts +102 -0
  123. package/lib/reportingStrings.ts +491 -0
  124. package/lib/reportingTagWeekBreakdown.test.ts +141 -0
  125. package/lib/reportingTagWeekBreakdown.ts +181 -0
  126. package/lib/reportingWeekLayout.test.ts +239 -0
  127. package/lib/reportingWeekLayout.ts +313 -0
  128. package/lib/sessionAssiduity.test.ts +25 -0
  129. package/lib/sessionAssiduity.ts +33 -0
  130. package/lib/sessionEndReason.ts +55 -0
  131. package/lib/sessionEndWarnings.test.ts +200 -0
  132. package/lib/sessionEndWarnings.ts +125 -0
  133. package/lib/sessionListMerge.test.ts +101 -0
  134. package/lib/sessionListMerge.ts +70 -0
  135. package/lib/sessionTaskSidebarStats.test.ts +24 -0
  136. package/lib/sessionTaskSidebarStats.ts +54 -0
  137. package/lib/settingsCopy.ts +1276 -0
  138. package/lib/taskParsing.test.ts +153 -0
  139. package/lib/taskParsing.ts +737 -0
  140. package/lib/theme.ts +15 -0
  141. package/lib/translucentButtonClasses.ts +34 -0
  142. package/lib/usageProfile.test.ts +84 -0
  143. package/lib/usageProfile.ts +52 -0
  144. package/lib/userGuideCopy.ts +464 -0
  145. package/lib/workspaceLocDefaults.ts +21 -0
  146. package/next-env.d.ts +6 -0
  147. package/next.config.ts +15 -0
  148. package/package.json +87 -0
  149. package/postcss.config.mjs +12 -0
  150. package/public/apple-icon.png +0 -0
  151. package/public/favicon.ico +0 -0
  152. package/public/file.svg +1 -0
  153. package/public/globe.svg +1 -0
  154. package/public/icon-192.png +0 -0
  155. package/public/icon-512.png +0 -0
  156. package/public/icon.png +0 -0
  157. package/public/next.svg +1 -0
  158. package/public/sw.js +13 -0
  159. package/public/traceback.png +0 -0
  160. package/public/vercel.svg +1 -0
  161. package/public/window.svg +1 -0
  162. package/server/actionDispatch.test.ts +723 -0
  163. package/server/actionDispatch.ts +1476 -0
  164. package/server/actionTaskSession.test.ts +713 -0
  165. package/server/actionTaskSession.ts +717 -0
  166. package/server/db.ts +42 -0
  167. package/server/defaultCfg.ts +87 -0
  168. package/server/gitlabTokenStore.ts +34 -0
  169. package/server/kronoFocusHydrate.test.ts +142 -0
  170. package/server/kronoFocusHydrate.ts +69 -0
  171. package/server/kronoFocusMigrate.test.ts +53 -0
  172. package/server/kronoFocusMigrate.ts +78 -0
  173. package/server/mainTimerHydrate.test.ts +65 -0
  174. package/server/mainTimerHydrate.ts +53 -0
  175. package/server/payloadStore.test.ts +78 -0
  176. package/server/payloadStore.ts +83 -0
  177. package/server/sessionWallHydrate.test.ts +46 -0
  178. package/server/sessionWallHydrate.ts +88 -0
  179. package/tsconfig.json +41 -0
@@ -0,0 +1,114 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useState } from "react";
4
+ import type { DashboardStrings } from "@/lib/dashboardCopy";
5
+ import { normalizeSessionEndReasonKind, normalizeSessionEndReasonNote } from "@/lib/sessionEndReason";
6
+
7
+ const REASON_OPTIONS = ["", "planned", "early", "overrun", "other"] as const;
8
+
9
+ export function SessionEndReasonEditor({
10
+ t,
11
+ radioGroupName,
12
+ sessionId,
13
+ initialKind,
14
+ initialNote,
15
+ post,
16
+ }: {
17
+ t: DashboardStrings;
18
+ /** Nom du groupe de boutons radio (unique par instance sur la page). */
19
+ radioGroupName: string;
20
+ sessionId: string;
21
+ initialKind?: string;
22
+ initialNote?: string;
23
+ post: (body: Record<string, unknown>) => Promise<unknown>;
24
+ }) {
25
+ const [kind, setKind] = useState("");
26
+ const [note, setNote] = useState("");
27
+ const [saving, setSaving] = useState(false);
28
+
29
+ useEffect(() => {
30
+ setKind(normalizeSessionEndReasonKind(initialKind) || "");
31
+ setNote(typeof initialNote === "string" ? initialNote : "");
32
+ }, [sessionId, initialKind, initialNote]);
33
+
34
+ const normalizedInitialKind = useMemo(() => normalizeSessionEndReasonKind(initialKind) || "", [initialKind]);
35
+ const normalizedInitialNote = useMemo(() => normalizeSessionEndReasonNote(initialNote), [initialNote]);
36
+ const currentKind = normalizeSessionEndReasonKind(kind) || "";
37
+ const currentNote = normalizeSessionEndReasonNote(note);
38
+ const dirty = currentKind !== normalizedInitialKind || currentNote !== normalizedInitialNote;
39
+
40
+ const optionLabels = useMemo(
41
+ () =>
42
+ ({
43
+ "": t.sessionEndReasonSkip,
44
+ planned: t.sessionEndReasonPlanned,
45
+ early: t.sessionEndReasonEarly,
46
+ overrun: t.sessionEndReasonOverrun,
47
+ other: t.sessionEndReasonOther,
48
+ }) as Record<(typeof REASON_OPTIONS)[number], string>,
49
+ [t]
50
+ );
51
+
52
+ const onSave = async () => {
53
+ setSaving(true);
54
+ try {
55
+ await post({
56
+ type: "setSessionEndReason",
57
+ sessionId,
58
+ sessionEndReasonKind: currentKind,
59
+ sessionEndReasonNote: currentNote,
60
+ });
61
+ } finally {
62
+ setSaving(false);
63
+ }
64
+ };
65
+
66
+ return (
67
+ <div className="space-y-3 text-left text-[0.75rem] leading-snug text-zinc-700 dark:text-zinc-300">
68
+ <p className="text-[0.7rem] leading-snug text-zinc-600 dark:text-zinc-400">{t.sessionEndReasonEditHint}</p>
69
+ <fieldset className="space-y-2 border-0 p-0">
70
+ <legend className="text-[0.65rem] font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-500">
71
+ {t.sessionEndReasonFieldsetLegend}
72
+ </legend>
73
+ {REASON_OPTIONS.map((value) => (
74
+ <label
75
+ key={value || "skip"}
76
+ className="flex cursor-pointer items-start gap-2 rounded-md py-0.5 pr-1 hover:bg-zinc-100/80 dark:hover:bg-zinc-800/50"
77
+ >
78
+ <input
79
+ type="radio"
80
+ name={radioGroupName}
81
+ className="mt-1 size-4 shrink-0 border-zinc-300 text-violet-600 focus:ring-violet-500/50 dark:border-zinc-600"
82
+ checked={currentKind === value}
83
+ onChange={() => setKind(value)}
84
+ />
85
+ <span className="leading-snug">{optionLabels[value]}</span>
86
+ </label>
87
+ ))}
88
+ </fieldset>
89
+ <label className="block">
90
+ <span className="sr-only">{t.sessionEndReasonNoteAria}</span>
91
+ <textarea
92
+ className="mt-1 w-full min-h-[4rem] resize-y rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 focus:border-violet-500/70 focus:outline-none focus:ring-2 focus:ring-violet-500/25 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:placeholder:text-zinc-500"
93
+ rows={3}
94
+ maxLength={500}
95
+ value={note}
96
+ onChange={(e) => setNote(e.target.value)}
97
+ placeholder={t.sessionEndReasonNotePlaceholder}
98
+ aria-label={t.sessionEndReasonNoteAria}
99
+ />
100
+ </label>
101
+ <div className="flex justify-end pt-1">
102
+ <button
103
+ type="button"
104
+ className="rounded-lg border border-violet-300 bg-violet-50 px-3 py-1.5 text-sm font-medium text-violet-900 transition hover:bg-violet-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-violet-700/80 dark:bg-violet-950/50 dark:text-violet-100 dark:hover:bg-violet-950/80"
105
+ disabled={!dirty || saving}
106
+ aria-label={t.sessionEndReasonSaveAria}
107
+ onClick={() => void onSave()}
108
+ >
109
+ {saving ? t.sessionEndReasonSaving : t.sessionEndReasonSaveBtn}
110
+ </button>
111
+ </div>
112
+ </div>
113
+ );
114
+ }
@@ -0,0 +1,320 @@
1
+ "use client";
2
+
3
+ import { Archive, Circle, ExternalLink, Loader2, Square, Trash2, UploadCloud } from "lucide-react";
4
+ import { sessionTaskCountNoun, type DashboardStrings, type Lang } from "@/lib/dashboardCopy";
5
+ import { DEFAULT_DASHBOARD_TIME_ZONE, isValidIanaTimeZone } from "@/lib/dashboardTimeZone";
6
+ import { formatIsoInstantShort } from "@/lib/formatIsoShort";
7
+ import { sessionWallClockMinutes, type LooseSession } from "@/lib/reportingAggregate";
8
+ import { formatDuration } from "@/lib/taskParsing";
9
+
10
+ export type SessionListEntry = {
11
+ sessionId: string;
12
+ sessionName?: string;
13
+ savedAt?: string;
14
+ /** Horodatage immuable de création de la session ; repli : `startAt` pour les anciennes données. */
15
+ createdAt?: string | null;
16
+ startAt?: string | null;
17
+ endAt?: string | null;
18
+ sessionDurationMinutes?: number;
19
+ /** Catégorie enregistrée à la clôture (session terminée). */
20
+ sessionEndReasonKind?: string;
21
+ /** Précision libre à la clôture. */
22
+ sessionEndReasonNote?: string;
23
+ tasks?: unknown[];
24
+ activeTasks?: unknown[];
25
+ activeTask?: unknown | null;
26
+ mongoPushedAt?: string;
27
+ mongoLastPushedSavedAt?: string;
28
+ };
29
+
30
+ function taskCount(s: SessionListEntry): number {
31
+ const listed = s.tasks?.length ?? 0;
32
+ const nActive =
33
+ Array.isArray(s.activeTasks) && s.activeTasks.length > 0
34
+ ? s.activeTasks.length
35
+ : s.activeTask
36
+ ? 1
37
+ : 0;
38
+ return listed + nActive;
39
+ }
40
+
41
+ function sessionMongoPushState(sess: SessionListEntry): "never" | "synced" | "dirty" {
42
+ const saved = sess.savedAt ?? "";
43
+ const lastPush = sess.mongoLastPushedSavedAt;
44
+ if (!lastPush) {
45
+ return "never";
46
+ }
47
+ return lastPush === saved ? "synced" : "dirty";
48
+ }
49
+
50
+ function sortSessions(sessions: SessionListEntry[]): SessionListEntry[] {
51
+ return [...sessions].sort((a, b) => {
52
+ const timeA = Date.parse(a.savedAt || a.createdAt || a.startAt || "") || 0;
53
+ const timeB = Date.parse(b.savedAt || b.createdAt || b.startAt || "") || 0;
54
+ return timeB - timeA;
55
+ });
56
+ }
57
+
58
+ /** Zone fixe (28×28) pour aligner les icônes sur la ligne date / tâches. */
59
+ const sessionRowActionBase =
60
+ "inline-flex size-7 shrink-0 items-center justify-center rounded-md border border-transparent hover:border-zinc-400 hover:bg-zinc-200/90 dark:hover:border-zinc-600 dark:hover:bg-zinc-800/80";
61
+
62
+ const sessionRowActionIconSize = 16;
63
+
64
+ export function SessionListPanel({
65
+ sessions,
66
+ liveSessionId,
67
+ selectedSessionId,
68
+ t,
69
+ onSelectSession,
70
+ onOpenSessionInNewTab,
71
+ onEndLiveSession,
72
+ onArchiveSession,
73
+ onDeleteSession,
74
+ onOpenArchives,
75
+ archivedCount = 0,
76
+ lang,
77
+ displayTimeZone = DEFAULT_DASHBOARD_TIME_ZONE,
78
+ use24HourClock = true,
79
+ mongoPushEnabled = false,
80
+ onPushSessionToMongo,
81
+ pushingSessionId = null,
82
+ sessionDurationAlertThresholdMinutes,
83
+ }: {
84
+ sessions: SessionListEntry[];
85
+ lang: Lang;
86
+ /** Fuseau IANA pour la date affichée sur chaque session. */
87
+ displayTimeZone?: string;
88
+ /** Format 12 h / 24 h pour les horodatages début et fin de session. */
89
+ use24HourClock?: boolean;
90
+ liveSessionId: string | undefined;
91
+ /** When inspecting history, the selected id; when live, equals liveSessionId */
92
+ selectedSessionId: string | undefined;
93
+ t: DashboardStrings;
94
+ onSelectSession: (sessionId: string) => void;
95
+ /** Ouvre `?session=id` dans un nouvel onglet (URL dédiée, sans imposer la session aux autres onglets). */
96
+ onOpenSessionInNewTab?: (sessionId: string) => void;
97
+ /** Session live uniquement : enregistrer l’instantané dans l’historique sans « Nouvelle session ». */
98
+ onEndLiveSession?: () => void;
99
+ onArchiveSession?: (sessionId: string) => void;
100
+ onDeleteSession?: (sessionId: string) => void;
101
+ onOpenArchives?: () => void;
102
+ archivedCount?: number;
103
+ /** Mongo activé + connexion OK : affiche le bouton d’envoi par session. */
104
+ mongoPushEnabled?: boolean;
105
+ onPushSessionToMongo?: (sessionId: string) => void;
106
+ pushingSessionId?: string | null;
107
+ /** Minutes murales : durée affichée en alerte si ≥ seuil (même réglage que le panneau session). */
108
+ sessionDurationAlertThresholdMinutes?: number;
109
+ }) {
110
+ const sorted = sortSessions(sessions);
111
+ const archivesLabel =
112
+ archivedCount > 0 ? `${t.archivesModalTitle} (${archivedCount})` : t.archivesModalTitle;
113
+
114
+ return (
115
+ <aside className="flex h-min min-w-0 max-w-full max-h-[calc(100vh-8rem)] flex-col rounded-xl border border-zinc-200 bg-white/90 shadow-sm dark:border-zinc-800 dark:bg-zinc-800/50 dark:shadow-none xl:z-0">
116
+ <div className="flex flex-wrap items-center justify-end gap-3 border-b border-zinc-200 px-5 py-3 dark:border-zinc-800">
117
+ {onOpenArchives ? (
118
+ <button
119
+ type="button"
120
+ className="relative inline-flex size-9 shrink-0 items-center justify-center rounded-md border border-zinc-300 text-zinc-600 hover:bg-zinc-100 dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-800"
121
+ onClick={onOpenArchives}
122
+ aria-label={archivesLabel}
123
+ title={archivesLabel}
124
+ >
125
+ <Archive size={18} strokeWidth={1.75} className="text-zinc-600 dark:text-zinc-300" aria-hidden />
126
+ {archivedCount > 0 ? (
127
+ <span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-violet-600 px-0.5 text-[0.6rem] font-semibold leading-none text-white">
128
+ {archivedCount > 99 ? "99+" : archivedCount}
129
+ </span>
130
+ ) : null}
131
+ </button>
132
+ ) : (
133
+ <span className="size-9 shrink-0" aria-hidden />
134
+ )}
135
+ </div>
136
+
137
+ <nav className="min-h-0 flex-1 overflow-y-auto px-3 py-3 pr-4" aria-label={t.sessionsListAriaLabel}>
138
+ <ul className="space-y-1.5">
139
+ {sorted.map((sess) => {
140
+ const id = sess.sessionId;
141
+ const isLive = id === liveSessionId;
142
+ const isSelected = id === selectedSessionId;
143
+ const n = taskCount(sess);
144
+ const tz =
145
+ displayTimeZone.trim() && isValidIanaTimeZone(displayTimeZone.trim())
146
+ ? displayTimeZone.trim()
147
+ : DEFAULT_DASHBOARD_TIME_ZONE;
148
+ const createdIso = (sess.createdAt?.trim() || sess.startAt?.trim() || sess.savedAt?.trim() || "").trim();
149
+ const createdLabel = formatIsoInstantShort(createdIso, lang, tz, use24HourClock);
150
+ const label = sess.sessionName?.trim() || (createdLabel ? `${id.slice(0, 8)} · ${createdLabel}` : id.slice(0, 8));
151
+ const startIso = (sess.startAt?.trim() || sess.savedAt?.trim() || "").trim();
152
+ const startLabel = formatIsoInstantShort(startIso, lang, tz, use24HourClock) ?? "—";
153
+ const hasEnd =
154
+ typeof sess.endAt === "string" && sess.endAt.trim() !== "";
155
+ const endLabel = hasEnd
156
+ ? formatIsoInstantShort(sess.endAt!.trim(), lang, tz, use24HourClock) ?? "—"
157
+ : null;
158
+
159
+ const taskNoun = sessionTaskCountNoun(n, t, lang);
160
+ const wallMins = sessionWallClockMinutes(sess as LooseSession);
161
+ const durationLabel = wallMins > 0 ? formatDuration(wallMins) : "—";
162
+ const thresholdMin =
163
+ typeof sessionDurationAlertThresholdMinutes === "number" &&
164
+ Number.isFinite(sessionDurationAlertThresholdMinutes)
165
+ ? Math.max(1, Math.round(sessionDurationAlertThresholdMinutes))
166
+ : null;
167
+ const durationAlert =
168
+ thresholdMin !== null && wallMins > 0 && wallMins >= thresholdMin;
169
+ const thresholdHours = thresholdMin !== null ? Math.round(thresholdMin / 60) : 0;
170
+ const durationTitle = durationAlert
171
+ ? t.sessionListWallDurationAlertTooltip.replace("{hours}", String(thresholdHours))
172
+ : t.sessionListWallDurationTitle;
173
+ const mongoState = sessionMongoPushState(sess);
174
+ const mongoTitle =
175
+ mongoState === "synced"
176
+ ? t.sessionMongoPushSyncedTitle
177
+ : mongoState === "dirty"
178
+ ? t.sessionMongoPushDirtyTitle
179
+ : t.sessionMongoPushNeverTitle;
180
+ const mongoIconClass =
181
+ mongoState === "synced"
182
+ ? "text-emerald-400/95"
183
+ : mongoState === "dirty"
184
+ ? "text-amber-400/95"
185
+ : "text-zinc-500";
186
+
187
+ return (
188
+ <li key={id} id={`kronosys-session-${id}`} className="scroll-mt-20">
189
+ <div
190
+ className={`grid grid-cols-[minmax(0,1fr)_auto] grid-rows-[auto_auto] gap-x-1.5 gap-y-1 rounded-lg px-3.5 py-3 transition-colors ${
191
+ isSelected
192
+ ? "bg-violet-500/15 ring-1 ring-violet-500/45 dark:bg-violet-600/20 dark:ring-violet-500/50"
193
+ : "hover:bg-zinc-100/90 dark:hover:bg-zinc-800/80"
194
+ }`}
195
+ >
196
+ <button
197
+ type="button"
198
+ onClick={() => onSelectSession(id)}
199
+ className="group col-start-1 row-start-1 row-span-2 flex min-w-0 flex-col gap-0.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-violet-500/60 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-900 rounded-md -mx-1 px-1 -my-0.5 py-0.5"
200
+ >
201
+ <span className="flex min-w-0 items-center gap-2 text-sm font-medium text-zinc-800 group-hover:text-zinc-950 dark:text-zinc-100 dark:group-hover:text-white">
202
+ {isLive && (
203
+ <Circle
204
+ className="shrink-0 fill-emerald-400 text-emerald-400"
205
+ size={10}
206
+ aria-hidden
207
+ />
208
+ )}
209
+ <span className="truncate">{label}</span>
210
+ {isLive && (
211
+ <span className="shrink-0 rounded bg-emerald-100 px-1.5 py-0 text-[0.6rem] font-bold uppercase tracking-wide text-emerald-900 ring-1 ring-emerald-600/20 dark:bg-emerald-900/50 dark:text-emerald-300 dark:ring-0">
212
+ {t.sessionLiveBadge}
213
+ </span>
214
+ )}
215
+ </span>
216
+ <span className="flex min-w-0 flex-col gap-0.5 text-[0.7rem] leading-snug text-zinc-500 group-hover:text-zinc-600 dark:group-hover:text-zinc-400">
217
+ <span className="min-w-0 break-words">
218
+ <span className="text-zinc-600 dark:text-zinc-500">{t.sessionListStartedPrefix}</span>{" "}
219
+ {startLabel}
220
+ </span>
221
+ {endLabel !== null ? (
222
+ <span className="min-w-0 break-words">
223
+ <span className="text-zinc-600 dark:text-zinc-500">{t.sessionListEndedPrefix}</span>{" "}
224
+ {endLabel}
225
+ </span>
226
+ ) : null}
227
+ <span className="min-w-0 truncate">
228
+ <span>
229
+ {n} {taskNoun}
230
+ </span>
231
+ <span className="text-zinc-400 dark:text-zinc-600"> · </span>
232
+ <span
233
+ className={`inline tabular-nums ${
234
+ durationAlert ? "kronosys-session-duration-alert font-semibold" : ""
235
+ }`}
236
+ title={durationTitle}
237
+ >
238
+ {durationLabel}
239
+ </span>
240
+ </span>
241
+ </span>
242
+ </button>
243
+ <div className="col-start-2 row-start-2 flex shrink-0 items-center gap-0.5 self-center">
244
+ {mongoPushEnabled && onPushSessionToMongo ? (
245
+ <button
246
+ type="button"
247
+ className={`${sessionRowActionBase} ${mongoIconClass} hover:text-sky-600 disabled:opacity-40 dark:hover:text-sky-300`}
248
+ title={mongoTitle}
249
+ aria-label={t.sessionMongoPushAriaLabel}
250
+ disabled={pushingSessionId === id}
251
+ onClick={(e) => {
252
+ e.stopPropagation();
253
+ onPushSessionToMongo(id);
254
+ }}
255
+ >
256
+ {pushingSessionId === id ? (
257
+ <Loader2
258
+ className="animate-spin text-zinc-500 dark:text-zinc-400"
259
+ size={sessionRowActionIconSize}
260
+ aria-label={t.sessionMongoPushBusy}
261
+ />
262
+ ) : (
263
+ <UploadCloud size={sessionRowActionIconSize} aria-hidden />
264
+ )}
265
+ </button>
266
+ ) : null}
267
+ {isLive && onEndLiveSession ? (
268
+ <button
269
+ type="button"
270
+ className={`${sessionRowActionBase} text-zinc-500 hover:text-rose-600 dark:hover:text-rose-300`}
271
+ title={t.sessionEndLiveTitle}
272
+ aria-label={t.sessionEndLiveAria}
273
+ onClick={() => onEndLiveSession()}
274
+ >
275
+ <Square size={sessionRowActionIconSize} aria-hidden />
276
+ </button>
277
+ ) : null}
278
+ {onOpenSessionInNewTab ? (
279
+ <button
280
+ type="button"
281
+ className={`${sessionRowActionBase} text-zinc-500 hover:text-zinc-800 dark:hover:text-zinc-200`}
282
+ title={t.openSessionInNewTab}
283
+ aria-label={t.openSessionInNewTab}
284
+ onClick={() => onOpenSessionInNewTab(id)}
285
+ >
286
+ <ExternalLink size={sessionRowActionIconSize} aria-hidden />
287
+ </button>
288
+ ) : null}
289
+ {onArchiveSession && !isLive ? (
290
+ <button
291
+ type="button"
292
+ className={`${sessionRowActionBase} text-zinc-500 hover:text-amber-700 dark:hover:text-amber-200/90`}
293
+ title={t.sessionArchiveTitle}
294
+ aria-label={t.sessionArchiveBtn}
295
+ onClick={() => onArchiveSession(id)}
296
+ >
297
+ <Archive size={sessionRowActionIconSize} aria-hidden />
298
+ </button>
299
+ ) : null}
300
+ {onDeleteSession && !isLive ? (
301
+ <button
302
+ type="button"
303
+ className={`${sessionRowActionBase} text-zinc-500 hover:text-red-600 dark:hover:text-red-300`}
304
+ title={t.sessionDeleteTitle}
305
+ aria-label={t.sessionDeleteBtn}
306
+ onClick={() => onDeleteSession(id)}
307
+ >
308
+ <Trash2 size={sessionRowActionIconSize} aria-hidden />
309
+ </button>
310
+ ) : null}
311
+ </div>
312
+ </div>
313
+ </li>
314
+ );
315
+ })}
316
+ </ul>
317
+ </nav>
318
+ </aside>
319
+ );
320
+ }
@@ -0,0 +1,128 @@
1
+ "use client";
2
+
3
+ import type { DashboardStrings } from "@/lib/dashboardCopy";
4
+ import { DashboardCollapsibleSection } from "./DashboardCollapsibleSection";
5
+ import { InlineMetricHelpTrigger } from "./InlineMetricHelpTrigger";
6
+
7
+ export type SessionLocMetricsShape = {
8
+ linesWrittenTotal?: number;
9
+ linesWrittenHuman?: number;
10
+ linesWrittenAi?: number;
11
+ locByLanguage?: Array<[string, number]>;
12
+ codingSignalsByLanguage?: Array<[string, number]>;
13
+ };
14
+
15
+ export function SessionLocMetricsSection({
16
+ session,
17
+ t,
18
+ }: {
19
+ session: SessionLocMetricsShape;
20
+ t: DashboardStrings;
21
+ }) {
22
+ const total = session.linesWrittenTotal ?? 0;
23
+ const human = session.linesWrittenHuman ?? 0;
24
+ const ai = session.linesWrittenAi ?? 0;
25
+ const locRows = [...(session.locByLanguage ?? [])].sort((a, b) => b[1] - a[1]);
26
+ const sigRows = [...(session.codingSignalsByLanguage ?? [])].sort((a, b) => b[1] - a[1]);
27
+
28
+ return (
29
+ <DashboardCollapsibleSection
30
+ sectionAriaLabel={t.statsLocSectionTitle}
31
+ title={
32
+ <span className="text-sm font-semibold text-zinc-800 dark:text-zinc-200">{t.statsLocSectionTitle}</span>
33
+ }
34
+ >
35
+ <div className="grid gap-4 sm:grid-cols-3">
36
+ <div>
37
+ <div className="flex min-h-5 items-center gap-0.5">
38
+ <span className="text-xs uppercase text-zinc-500 dark:text-zinc-500">{t.statsLinesWrittenTotal}</span>
39
+ <InlineMetricHelpTrigger
40
+ ariaLabel={t.statsMetricLinesWrittenHelpAria}
41
+ body={t.statsMetricLinesWrittenHelpBody}
42
+ />
43
+ </div>
44
+ <div className="mt-1 text-2xl font-semibold tabular-nums text-zinc-900 dark:text-zinc-100">{total}</div>
45
+ </div>
46
+ <div>
47
+ <div className="flex min-h-5 items-center gap-0.5">
48
+ <span className="text-xs uppercase text-zinc-500 dark:text-zinc-500">{t.statsLinesWrittenHuman}</span>
49
+ <InlineMetricHelpTrigger
50
+ ariaLabel={t.statsMetricLinesWrittenHelpAria}
51
+ body={t.statsMetricLinesWrittenHelpBody}
52
+ />
53
+ </div>
54
+ <div className="mt-1 text-2xl font-semibold tabular-nums text-emerald-400/90">{human}</div>
55
+ </div>
56
+ <div>
57
+ <div className="flex min-h-5 items-center gap-0.5">
58
+ <span className="text-xs uppercase text-zinc-500 dark:text-zinc-500">{t.statsLinesWrittenAi}</span>
59
+ <InlineMetricHelpTrigger
60
+ ariaLabel={t.statsMetricLinesWrittenHelpAria}
61
+ body={t.statsMetricLinesWrittenHelpBody}
62
+ />
63
+ </div>
64
+ <div className="mt-1 text-2xl font-semibold tabular-nums text-violet-400/90">{ai}</div>
65
+ </div>
66
+ </div>
67
+
68
+ <div className="mt-6 grid gap-6 lg:grid-cols-2">
69
+ <div>
70
+ <div className="mb-2 flex min-h-5 items-center gap-0.5">
71
+ <span className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-500">
72
+ {t.statsLocByLanguageHeading}
73
+ </span>
74
+ <InlineMetricHelpTrigger
75
+ ariaLabel={t.statsMetricLocByLanguageHelpAria}
76
+ body={t.statsMetricLocByLanguageHelpBody}
77
+ />
78
+ </div>
79
+ {locRows.length === 0 ? (
80
+ <p className="text-sm text-zinc-500">{t.noData}</p>
81
+ ) : (
82
+ <ul className="divide-y divide-zinc-200 rounded-lg border border-zinc-200 dark:divide-zinc-800/90 dark:border-zinc-800/80">
83
+ {locRows.map(([lang, n]) => (
84
+ <li
85
+ key={lang}
86
+ className="flex items-center justify-between gap-3 px-3 py-2 text-sm text-zinc-800 dark:text-zinc-200"
87
+ >
88
+ <span className="min-w-0 truncate font-mono text-xs text-zinc-500 dark:text-zinc-400">
89
+ {lang}
90
+ </span>
91
+ <span className="shrink-0 tabular-nums text-zinc-900 dark:text-zinc-100">{n}</span>
92
+ </li>
93
+ ))}
94
+ </ul>
95
+ )}
96
+ </div>
97
+ <div>
98
+ <div className="mb-2 flex min-h-5 items-center gap-0.5">
99
+ <span className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-500">
100
+ {t.statsCodingSignalsHeading}
101
+ </span>
102
+ <InlineMetricHelpTrigger
103
+ ariaLabel={t.statsMetricCodingSignalsHelpAria}
104
+ body={t.statsMetricCodingSignalsHelpBody}
105
+ />
106
+ </div>
107
+ {sigRows.length === 0 ? (
108
+ <p className="text-sm text-zinc-500">{t.noData}</p>
109
+ ) : (
110
+ <ul className="divide-y divide-zinc-200 rounded-lg border border-zinc-200 dark:divide-zinc-800/90 dark:border-zinc-800/80">
111
+ {sigRows.map(([lang, n]) => (
112
+ <li
113
+ key={lang}
114
+ className="flex items-center justify-between gap-3 px-3 py-2 text-sm text-zinc-800 dark:text-zinc-200"
115
+ >
116
+ <span className="min-w-0 truncate font-mono text-xs text-zinc-500 dark:text-zinc-400">
117
+ {lang}
118
+ </span>
119
+ <span className="shrink-0 tabular-nums text-zinc-900 dark:text-zinc-100">{n}</span>
120
+ </li>
121
+ ))}
122
+ </ul>
123
+ )}
124
+ </div>
125
+ </div>
126
+ </DashboardCollapsibleSection>
127
+ );
128
+ }