Skip to content

Commit f199d7d

Browse files
committed
Change timeblock display to have the time text from .content, and just shown as a prepended time lozenge.
Fix to missing endTime in timeblocks.
1 parent c80221d commit f199d7d

9 files changed

Lines changed: 383 additions & 160 deletions

File tree

‎helpers/__tests__/timeblocks.test.js‎

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,4 +506,156 @@ describe(`${HELPER_NAME}`, () => {
506506
jest.useRealTimers() // Restore the real timers after each test
507507
})
508508
})
509+
510+
describe(section`stripTimeBlockAndMustContainFromContent()`, () => {
511+
test('strips a start-end timeblock from mid-content without must-contain string', () => {
512+
expect(tb.stripTimeBlockAndMustContainFromContent('Longer timeblocked task at 10:00-16:30')).toEqual('Longer timeblocked task at')
513+
})
514+
515+
test('strips a start-end timeblock from mid-content with must-contain string', () => {
516+
expect(tb.stripTimeBlockAndMustContainFromContent('Longer timeblocked task at 10:00-16:30', 'at')).toEqual('Longer timeblocked task')
517+
})
518+
519+
test('strips a start-only timeblock', () => {
520+
expect(tb.stripTimeBlockAndMustContainFromContent('Morning checklist 7:30AM')).toEqual('Morning checklist')
521+
})
522+
523+
test('strips timeblock when it is at the start of the line', () => {
524+
expect(tb.stripTimeBlockAndMustContainFromContent('10:00-11:00 Focus time')).toEqual('Focus time')
525+
})
526+
527+
test('strips NotePlan must-contain string when set', () => {
528+
expect(tb.stripTimeBlockAndMustContainFromContent('Meeting >anytime at 14:00-15:00', '>anytime at')).toEqual('Meeting')
529+
})
530+
531+
test('strips must-contain hashtag and timeblock', () => {
532+
expect(tb.stripTimeBlockAndMustContainFromContent('Deep work #tb 09:00-12:00', '#tb')).toEqual('Deep work')
533+
})
534+
535+
test('strips must-contain hashtag separate from the timeblock', () => {
536+
expect(tb.stripTimeBlockAndMustContainFromContent('#tb Deep work 09:00-12:00', '#tb')).toEqual('Deep work')
537+
})
538+
539+
test('returns trimmed content unchanged when no timeblock present', () => {
540+
expect(tb.stripTimeBlockAndMustContainFromContent(' no times here ')).toEqual('no times here')
541+
})
542+
543+
test('returns empty string for empty content', () => {
544+
expect(tb.stripTimeBlockAndMustContainFromContent('')).toEqual('')
545+
})
546+
547+
test('collapses leftover double spaces after strip', () => {
548+
expect(tb.stripTimeBlockAndMustContainFromContent('Task 10:00-11:00 remaining')).toEqual('Task remaining')
549+
})
550+
})
551+
552+
describe(section`formatTimeBlockLabelFromStartAndEnd()`, () => {
553+
test('formats start and end', () => {
554+
expect(tb.formatTimeBlockLabelFromStartAndEnd('10:00', '16:30')).toEqual('10:00-16:30')
555+
})
556+
557+
test('formats start only when end missing', () => {
558+
expect(tb.formatTimeBlockLabelFromStartAndEnd('07:30')).toEqual('07:30')
559+
})
560+
561+
test('returns empty when start is none or missing', () => {
562+
expect(tb.formatTimeBlockLabelFromStartAndEnd('none', '16:30')).toEqual('')
563+
expect(tb.formatTimeBlockLabelFromStartAndEnd(null)).toEqual('')
564+
expect(tb.formatTimeBlockLabelFromStartAndEnd(undefined)).toEqual('')
565+
})
566+
})
567+
568+
describe(section`getEndTimeObjFromParaContent()`, () => {
569+
test('parses 24h start-end without AM/PM', () => {
570+
expect(tb.getEndTimeObjFromParaContent('TEST at 22:30-23:45')).toEqual({ hours: 23, mins: 45 })
571+
})
572+
573+
test('returns NaN hours when there is no end time', () => {
574+
const result = tb.getEndTimeObjFromParaContent('Something at 22:30')
575+
expect(Number.isNaN(result.hours)).toBe(true)
576+
})
577+
578+
test('parses en-dash separator', () => {
579+
expect(tb.getEndTimeObjFromParaContent('Block 10:00–11:30')).toEqual({ hours: 11, mins: 30 })
580+
})
581+
})
582+
583+
describe(section`normalizeTimeBlockStartToHHMM()`, () => {
584+
test('pads single-digit hour', () => {
585+
expect(tb.normalizeTimeBlockStartToHHMM('9:00')).toEqual('09:00')
586+
})
587+
588+
test('strips AM suffix', () => {
589+
expect(tb.normalizeTimeBlockStartToHHMM('10:00AM')).toEqual('10:00')
590+
})
591+
592+
test('converts PM to 24h', () => {
593+
expect(tb.normalizeTimeBlockStartToHHMM('11:00 PM')).toEqual('23:00')
594+
})
595+
596+
test('keeps noon as 12:00', () => {
597+
expect(tb.normalizeTimeBlockStartToHHMM('12:00 PM')).toEqual('12:00')
598+
})
599+
})
600+
601+
describe(section`getStartTimeFromPara()`, () => {
602+
test('should return 10:00 from 10:00-11:00', () => {
603+
expect(tb.getStartTimeFromPara({ content: 'just a time range 10:00-11:00' })).toBe('10:00')
604+
})
605+
test('should return 10:00 from 10:00', () => {
606+
expect(tb.getStartTimeFromPara({ content: 'just a time 10:00' })).toBe('10:00')
607+
})
608+
test('should return 10:00 from 10:00AM-11:00PM', () => {
609+
expect(tb.getStartTimeFromPara({ content: '2025-05-09 10:00AM-11:00PM' })).toBe('10:00')
610+
})
611+
test('should return 10:00 from 10:00AM', () => {
612+
expect(tb.getStartTimeFromPara({ content: '2025-05-09 10:00AM' })).toBe('10:00')
613+
})
614+
test('should return 10:00 from spaced AM', () => {
615+
expect(tb.getStartTimeFromPara({ content: 'other text 10:00 AM' })).toBe('10:00')
616+
})
617+
test('should return 23:00 from 11:00 PM', () => {
618+
expect(tb.getStartTimeFromPara({ content: 'other text 11:00 PM' })).toBe('23:00')
619+
})
620+
test('should return 12:00 from 12:00 PM (noon)', () => {
621+
expect(tb.getStartTimeFromPara({ content: 'Lunch 12:00 PM' })).toBe('12:00')
622+
})
623+
test('should return 12:30 from 12:30 PM', () => {
624+
expect(tb.getStartTimeFromPara({ content: 'Lunch 12:30 PM' })).toBe('12:30')
625+
})
626+
test('should return 09:00 from single-digit hour 9:00 AM', () => {
627+
expect(tb.getStartTimeFromPara({ content: 'Standup 9:00 AM' })).toBe('09:00')
628+
})
629+
test('should return "none" if the para does not have a valid time', () => {
630+
expect(tb.getStartTimeFromPara({ content: '2025-05-09 10-11' })).toBe('none')
631+
})
632+
})
633+
634+
describe(section`getEndTimeFromPara()`, () => {
635+
test('returns normalised end from 24h range', () => {
636+
expect(tb.getEndTimeFromPara({ content: 'TEST at 22:30-23:45' })).toBe('23:45')
637+
})
638+
639+
test('returns undefined when there is no end', () => {
640+
expect(tb.getEndTimeFromPara({ content: 'Something at 22:30' })).toBeUndefined()
641+
})
642+
643+
test('normalises PM end time', () => {
644+
expect(tb.getEndTimeFromPara({ content: 'Meeting 10:00AM-11:00PM' })).toBe('23:00')
645+
})
646+
})
647+
648+
describe(section`prepareTimeBlockContentForDisplay()`, () => {
649+
test('puts normalised time at start conceptually (label + cleaned rest)', () => {
650+
const result = tb.prepareTimeBlockContentForDisplay('Longer timeblocked task at 10:00-16:30', '10:00', '16:30', 'at')
651+
expect(result.timeLabel).toEqual('10:00-16:30')
652+
expect(result.restContent).toEqual('Longer timeblocked task')
653+
})
654+
655+
test('handles must-contain string and start-only time', () => {
656+
const result = tb.prepareTimeBlockContentForDisplay('Standup >anytime 09:00', '09:00', undefined, '>anytime')
657+
expect(result.timeLabel).toEqual('09:00')
658+
expect(result.restContent).toEqual('Standup')
659+
})
660+
})
509661
})

‎helpers/timeblocks.js‎

Lines changed: 139 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import moment from 'moment/min/moment-with-locales'
77
import { clo, JSP, logDebug, logInfo, logError } from './dev'
88
import { displayTitle } from './general'
99
import { isTermInURL, stripAllURIsAndNoteLinks, stripDoneDateTimeMentions } from './paragraph'
10+
import { escapeRegExp } from './regexEscape'
1011
import { findLongestStringInArray } from './utils'
1112

1213
// import { getTime } from "date-fns";
@@ -354,6 +355,69 @@ export function getEndTimeStrFromParaContent(content: string): string {
354355
}
355356
}
356357

358+
/**
359+
* Normalize a time-block segment to 24h HH:mm (e.g. "12:00PM" -> "12:00", "9:00 AM" -> "09:00").
360+
* Works for either the start or end segment of a timeblock.
361+
* @param {string} startTimeStrIn - text before or after '-' in a time block
362+
* @returns {string}
363+
*/
364+
export function normalizeTimeBlockStartToHHMM(startTimeStrIn: string): string {
365+
let startTimeStr = startTimeStrIn
366+
if (startTimeStr.length > 0 && startTimeStr.length < 2) {
367+
startTimeStr = `0${startTimeStr}`
368+
} else if (startTimeStr.length > 1 && startTimeStr[1] === ':') {
369+
startTimeStr = `0${startTimeStr}`
370+
}
371+
if (startTimeStr.endsWith('AM')) {
372+
startTimeStr = startTimeStr.slice(0, 5)
373+
}
374+
if (startTimeStr.endsWith('PM')) {
375+
const hour = Number(startTimeStr.slice(0, 2))
376+
const adjustedHour = hour === 12 ? 12 : hour + 12
377+
startTimeStr = String(adjustedHour).padStart(2, '0') + startTimeStr.slice(2, 5)
378+
}
379+
return startTimeStr
380+
}
381+
382+
/**
383+
* Return the normalised start time (HH:mm) from a paragraph's content timeblock, or 'none'.
384+
* Copes with 'AM' and 'PM' suffixes. Not fully internationalised.
385+
* @param {{ content: string }} para - anything with a .content string (TParagraph, dashboard para, etc.)
386+
* @returns {string} e.g. '10:00', or 'none' / '(error)'
387+
*/
388+
export function getStartTimeFromPara(para: { content: string }): string {
389+
try {
390+
let startTimeStr = 'none'
391+
const thisTimeStr = getTimeBlockString(para.content)
392+
if (thisTimeStr !== '') {
393+
startTimeStr = normalizeTimeBlockStartToHHMM(thisTimeStr.split(/[-–~]/)[0])
394+
}
395+
return startTimeStr
396+
} catch (error) {
397+
logError('getStartTimeFromPara', `${error.message}`)
398+
return '(error)'
399+
}
400+
}
401+
402+
/**
403+
* Return the normalised end time (HH:mm) from a paragraph's content timeblock, if present.
404+
* Copes with 'AM' and 'PM' suffixes (same normalisation as getStartTimeFromPara).
405+
* @param {{ content: string }} para - anything with a .content string (TParagraph, dashboard para, etc.)
406+
* @returns {string | void} HH:mm end time, or undefined if none / error
407+
*/
408+
export function getEndTimeFromPara(para: { content: string }): string | void {
409+
try {
410+
const thisTimeStr = getTimeBlockString(para.content)
411+
if (thisTimeStr === '') return
412+
const endPart = thisTimeStr.split(/[-–~]/)[1]
413+
if (!endPart) return
414+
return normalizeTimeBlockStartToHHMM(endPart)
415+
} catch (error) {
416+
logError('getEndTimeFromPara', `${error.message}`)
417+
return
418+
}
419+
}
420+
357421
/**
358422
* Return the start time of a time block in a given paragraph, or else 'none' (which will then sort after times)
359423
* Copes with 'AM' and 'PM' suffixes. Note: Not fully internationalised (but then I don't think the rest of NP accepts non-Western numerals)
@@ -394,36 +458,41 @@ export function getStartTimeObjFromParaContent(content: string): ?{ hours: numbe
394458
}
395459

396460
/**
397-
* Return the end time of time block in a given paragraph, or else 'none' (which will then sort after times)
461+
* Return the end time of time block in a given paragraph, or else empty NaN hours/mins when absent.
398462
* Copes with 'AM' and 'PM' suffixes. Note: Not fully internationalised (but then I don't think the rest of NP accepts non-Western numerals)
399463
* @param {string} content to process
400-
* @returns {{number, number}} {hours, minutes} in 24 hour clock
464+
* @returns {{ hours: number, mins: number }} {hours, minutes} in 24 hour clock (NaN if no end time)
401465
*/
402466
export function getEndTimeObjFromParaContent(content: string): { hours: number, mins: number } {
403467
try {
404-
let endTimeStr = 'none'
405468
let hours = NaN
406469
let mins = NaN
407470
if (content !== '') {
408471
const thisTimeStr = getTimeBlockString(content)
409472
if (thisTimeStr !== '') {
410-
endTimeStr = thisTimeStr.split('-')[1]
473+
// Prefer ASCII hyphen; also accept en-dash / tilde separators used in some timeblocks
474+
const endTimeStr = thisTimeStr.split(/[-–~]/)[1]
475+
if (!endTimeStr) {
476+
return { hours: NaN, mins: NaN }
477+
}
478+
// Note: split with RE_AMPM_OPT as a string (same as getStartTimeObjFromParaContent) - not as RegExp,
479+
// because the optional group would match empty strings and shred the time.
411480
const [timeStr, ampm] = endTimeStr.split(RE_AMPM_OPT)
412481
if (timeStr.includes(':')) {
413482
;[hours, mins] = timeStr.split(':').map(Number)
414483
} else {
415484
hours = Number(timeStr)
485+
mins = 0
416486
}
417-
if (ampm.toLowerCase() === 'pm' && hours !== 12) {
487+
if (ampm && ampm.toLowerCase() === 'pm' && hours !== 12) {
418488
hours += 12
419-
} else if (ampm.toLowerCase() === 'am' && hours === 12) {
489+
} else if (ampm && ampm.toLowerCase() === 'am' && hours === 12) {
420490
hours = 0
421491
}
422492
logDebug('getEndTimeObjFromParaContent', `timeStr = ${endTimeStr} from timeblock ${thisTimeStr}`)
423493
}
424494
}
425-
const startTime = { hours: hours, mins: mins }
426-
return startTime
495+
return { hours: hours, mins: mins }
427496
} catch (error) {
428497
logError('getEndTimeObjFromParaContent', `${JSP(error)}`)
429498
return { hours: NaN, mins: NaN }
@@ -525,3 +594,65 @@ export function getTimeBlockDetails(content: string, timeblockTextMustContainStr
525594
return ['', ''] // Return an empty tuple as a fallback
526595
}
527596
}
597+
598+
/**
599+
* Strip the timeblock string and optional NotePlan "Text must contain" marker from paragraph content.
600+
* Also collapses leftover whitespace.
601+
* @param {string} content - paragraph content that may include a timeblock
602+
* @param {string} timeblockMustContainString - NotePlan preference string that may be empty
603+
* @returns {string} content with timeblock (and must-contain marker) removed
604+
*/
605+
export function stripTimeBlockAndMustContainFromContent(content: string, timeblockMustContainString: string = ''): string {
606+
try {
607+
if (!content) return ''
608+
const timeBlockString = getTimeBlockString(content)
609+
let cleaned = content
610+
if (timeBlockString !== '') {
611+
// Remove the timeblock itself
612+
cleaned = cleaned.replace(timeBlockString, '')
613+
}
614+
// then the must-contain string
615+
if (timeblockMustContainString !== '') {
616+
cleaned = cleaned.replace(timeblockMustContainString, '')
617+
}
618+
return cleaned.replace(/\s{2,}/g, ' ').trim()
619+
} catch (err) {
620+
logError('stripTimeBlockAndMustContainFromContent', err.message)
621+
return content ?? ''
622+
}
623+
}
624+
625+
/**
626+
* Build a display time label from dashboard para startTime/endTime (already normalised HH:mm).
627+
* @param {?string} startTime - e.g. '10:00', or 'none'/empty if absent
628+
* @param {?string} endTime - e.g. '16:30', or absent when there is no end
629+
* @returns {string} e.g. '10:00-16:30', '10:00', or ''
630+
*/
631+
export function formatTimeBlockLabelFromStartAndEnd(startTime: ?string, endTime: ?string): string {
632+
if (!startTime || startTime === 'none') return ''
633+
if (endTime && endTime !== 'none') {
634+
return `${startTime}-${endTime}`
635+
}
636+
return startTime
637+
}
638+
639+
/**
640+
* Prepare timeblock content for display with the time always at the start.
641+
* Strips the original timeblock and "Text must contain" marker from content, and builds a leading
642+
* time label from the para's startTime/endTime fields.
643+
* @param {string} content - original paragraph content
644+
* @param {?string} startTime - normalised start from TParagraphForDashboard
645+
* @param {?string} endTime - normalised end from TParagraphForDashboard (optional)
646+
* @param {string} timeblockMustContainString - NotePlan preference string that may be empty
647+
* @returns {{ timeLabel: string, restContent: string }}
648+
*/
649+
export function prepareTimeBlockContentForDisplay(
650+
content: string,
651+
startTime: ?string,
652+
endTime: ?string,
653+
timeblockMustContainString: string = '',
654+
): { timeLabel: string, restContent: string } {
655+
const timeLabel = formatTimeBlockLabelFromStartAndEnd(startTime, endTime)
656+
const restContent = stripTimeBlockAndMustContainFromContent(content, timeblockMustContainString)
657+
return { timeLabel, restContent }
658+
}

‎jgclark.Dashboard/CHANGELOG.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ For more details see the [plugin's documentation](https://github.com/NotePlan/pl
1515
- Added workarounds for newly-discovered NP API bugs/quirks for reading/writing Reminders.
1616
- When Reminders section is enabled, the **Current time block** section is titled **Timed Items**, and timed reminders only appear there once their due time has been reached.
1717
- The "Current Time block" section is now titled just "Time Block" when Reminders are turned off.
18-
- Time displays on timeblock and reminder items now use a neater lozenge with a clock icon at the start.
18+
- Time displays on timeblock and reminder items now use a neater lozenge with a clock icon, always placed at the start of the line (original mid-line timeblock and NotePlan "Text must contain" marker are stripped from the remaining text).
1919
- Scheduled `>date` markers on items are now shown in a neater lozenge with a calendar icon at the start.
2020
- Note context links (`ItemNoteLink`) now use a tint-colored lozenge with border and smaller text, wrapping teamspace, folder, and note title while keeping their existing icons and colors.
2121
- Removed beta feature flag item "Show Banner Test Buttons".

0 commit comments

Comments
 (0)