diff --git a/app/lib/constants.ts b/app/lib/constants.ts index f91f6b5..764d47b 100644 --- a/app/lib/constants.ts +++ b/app/lib/constants.ts @@ -4,7 +4,7 @@ export const RequiredVersions = { controller: VERSION, tts: '2.0.0', piper: '1.3.0', - sounder: '2.0.0' + sounder: '2.1.0' } export const DOCS_URL = `https://openschoolbell.co.uk` diff --git a/app/lib/i18n.server.ts b/app/lib/i18n.server.ts index ac66330..2795391 100644 --- a/app/lib/i18n.server.ts +++ b/app/lib/i18n.server.ts @@ -46,7 +46,10 @@ const getMessages = (locale: SupportedLocale): Messages => { const messages: Messages = {} ;(Object.keys(locales[FALLBACK_LOCALE]) as MessageKey[]).forEach(key => { - messages[key] = locales[locale][key] ?? locales[FALLBACK_LOCALE][key] + messages[key] = + (locales[locale] as Partial<(typeof locales)[typeof FALLBACK_LOCALE]>)[ + key + ] ?? locales[FALLBACK_LOCALE][key] }) return messages diff --git a/app/lib/sequence-builder.tsx b/app/lib/sequence-builder.tsx new file mode 100644 index 0000000..bdd8f92 --- /dev/null +++ b/app/lib/sequence-builder.tsx @@ -0,0 +1,132 @@ +import {useState, useEffect} from 'react' +import {type Audio} from '@prisma/client' + +import CopyIcon from '@heroicons/react/24/outline/DocumentDuplicateIcon' +import PasteIcon from '@heroicons/react/24/outline/ClipboardDocumentIcon' + +import {getSecondsAsTime, INPUT_CLASSES} from './utils' +import {HelperText} from './ui' +import {useTranslation} from './i18n' + +export const SequenceBuilder = ({ + sounds, + initialQueue, + name, + label, + helperText +}: { + sounds: Audio[] + initialQueue: string[] + name: string + label: string + helperText: string +}) => { + const [queue, setQueue] = useState(initialQueue) + const [selected, setSelected] = useState(sounds[0].id) + const {t} = useTranslation() + + let duration = 0 + + return ( +
+ {label} +
+ {helperText} +
+ +
+ + +
+ + +
+
+
+ {queue.map((queuedId, i) => { + const sound = sounds.filter(({id}) => { + return id === queuedId + })[0] + + duration += sound.duration + + return ( +
+

{sound.name}

+ +

+ {getSecondsAsTime(sound.duration)} +

+
+ ) + })} +
+ {t('broadcast.builder.totalDuration', { + duration: getSecondsAsTime(duration) + })} +
+
+
+ ) +} diff --git a/app/locales/en.ts b/app/locales/en.ts index f7a50b4..404df81 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -212,8 +212,7 @@ export const en = { 'schedule.defaultOption': 'Default', 'schedule.table.time': 'Time', 'schedule.table.zone': 'Zone', - 'schedule.table.sound': 'Sound', - 'schedule.table.count': 'Count', + 'schedule.table.sequenceLength': 'Sequence Length', 'schedule.addButton': 'Add entry', 'schedule.add.pageTitle': 'Add schedule entry', 'schedule.form.time.label': 'Time', @@ -223,10 +222,9 @@ export const en = { 'schedule.form.day.helper': 'Day type that this schedule entry applies to.', 'schedule.form.zone.label': 'Zone', 'schedule.form.zone.helper': 'Zone where this schedule entry applies.', - 'schedule.form.sound.label': 'Sound', - 'schedule.form.sound.helper': 'Sound to play for this schedule entry.', - 'schedule.form.count.label': 'Repeat count', - 'schedule.form.count.helper': 'How many times to play the sound.', + 'schedule.form.sequence.label': 'Sequence', + 'schedule.form.sequence.helper': + 'The sequence of sounds to play at this schedule. You can add the same sound multiple times.', 'schedule.add.submit': 'Add entry', 'schedule.error.noDays': 'At least one day must be selected', 'schedule.edit.metaTitle': 'Edit {{time}}', diff --git a/app/routes/schedule.$schedule.tsx b/app/routes/schedule.$schedule.tsx index 4ccee60..fde7458 100644 --- a/app/routes/schedule.$schedule.tsx +++ b/app/routes/schedule.$schedule.tsx @@ -15,6 +15,7 @@ import {useTranslation} from '~/lib/i18n' import {translate} from '~/lib/i18n.shared' import {getRootI18n} from '~/lib/i18n.meta' import {initTranslations} from '~/lib/i18n.server' +import {SequenceBuilder} from '~/lib/sequence-builder' export const meta: MetaFunction = ({data, matches}) => { const {messages} = getRootI18n(matches) @@ -94,14 +95,12 @@ export const action: ActionFunction = async ({request, params}) => { const time = formData.get('time') as string | undefined const zone = formData.get('zone') as string | undefined const day = formData.get('dayType') as string | undefined - const sound = formData.get('sound') as string | undefined - const count = formData.get('count') as string | undefined + const sequence = formData.get('sequence') as string | undefined invariant(time) invariant(zone) invariant(day) - invariant(sound) - invariant(count) + invariant(sequence) await prisma.schedule.update({ where: {id: params.schedule}, @@ -110,8 +109,9 @@ export const action: ActionFunction = async ({request, params}) => { time, zoneId: zone, dayTypeId: day === '_' ? undefined : day, - audioId: sound, - count: parseInt(count) + audioId: JSON.parse(sequence)[0], + count: 0, + audioSequence: sequence } }) @@ -199,35 +199,13 @@ const EditSchedule = () => { })} - - - - - - + { {t('calendar.weekdays.saturday')} {t('calendar.weekdays.sunday')} {t('schedule.table.zone')} - {t('schedule.table.sound')} - {t('schedule.table.count')} + {t('schedule.table.sequenceLength')} @@ -86,7 +85,7 @@ const Schedule = () => { .filter(({dayTypeId}) => { return dayTypeId === (day === '_' ? null : day) }) - .map(({id, time, weekDays, zone, audio, count}) => { + .map(({id, time, weekDays, zone, audioSequence}) => { const days = weekDays.split(',') return ( @@ -117,9 +116,8 @@ const Schedule = () => { {zone.name} - {audio.name} + {(JSON.parse(audioSequence) as string[]).length} - {count}
diff --git a/app/routes/schedule.add.tsx b/app/routes/schedule.add.tsx index 085d5a2..9e51531 100644 --- a/app/routes/schedule.add.tsx +++ b/app/routes/schedule.add.tsx @@ -16,6 +16,7 @@ import {useTranslation} from '~/lib/i18n' import {translate} from '~/lib/i18n.shared' import {getRootI18n} from '~/lib/i18n.meta' import {initTranslations} from '~/lib/i18n.server' +import {SequenceBuilder} from '~/lib/sequence-builder' export const meta: MetaFunction = ({matches}) => { const {messages} = getRootI18n(matches) @@ -90,14 +91,12 @@ export const action: ActionFunction = async ({request}) => { const time = formData.get('time') as string | undefined const zone = formData.get('zone') as string | undefined const day = formData.get('dayType') as string | undefined - const sound = formData.get('sound') as string | undefined - const count = formData.get('count') as string | undefined + const sequence = formData.get('sequence') as string | undefined invariant(time) invariant(zone) invariant(day) - invariant(sound) - invariant(count) + invariant(sequence) await prisma.schedule.create({ data: { @@ -105,8 +104,9 @@ export const action: ActionFunction = async ({request}) => { time, zoneId: zone, dayTypeId: day === '_' ? undefined : day, - audioId: sound, - count: parseInt(count) + audioId: JSON.parse(sequence)[0], + count: 0, + audioSequence: sequence } }) @@ -192,41 +192,13 @@ const AddSchedule = () => { })} - - - - - { - setCount(e.target.value) - }} - /> - + { } }) - const data = schedules.map(({time, dayTypeId, weekDays, audioId, count}) => { + const data = schedules.map(({time, dayTypeId, weekDays, audioSequence}) => { return { time, day: dayTypeId ? dayTypeId : 'null', weekDays, - soundId: audioId, - count + sequence: audioSequence } }) diff --git a/prisma/migrations/20251021104121_add_audio_sequence_to_schedule/migration.sql b/prisma/migrations/20251021104121_add_audio_sequence_to_schedule/migration.sql new file mode 100644 index 0000000..c42a661 --- /dev/null +++ b/prisma/migrations/20251021104121_add_audio_sequence_to_schedule/migration.sql @@ -0,0 +1,21 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Schedule" ( + "id" TEXT NOT NULL PRIMARY KEY, + "time" TEXT NOT NULL, + "weekDays" TEXT NOT NULL, + "count" INTEGER NOT NULL DEFAULT 1, + "zoneId" TEXT NOT NULL, + "dayTypeId" TEXT, + "audioId" TEXT NOT NULL, + "audioSequence" TEXT NOT NULL DEFAULT '', + CONSTRAINT "Schedule_zoneId_fkey" FOREIGN KEY ("zoneId") REFERENCES "Zone" ("id") ON DELETE RESTRICT ON UPDATE CASCADE, + CONSTRAINT "Schedule_dayTypeId_fkey" FOREIGN KEY ("dayTypeId") REFERENCES "DayType" ("id") ON DELETE SET NULL ON UPDATE CASCADE, + CONSTRAINT "Schedule_audioId_fkey" FOREIGN KEY ("audioId") REFERENCES "Audio" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_Schedule" ("audioId", "count", "dayTypeId", "id", "time", "weekDays", "zoneId") SELECT "audioId", "count", "dayTypeId", "id", "time", "weekDays", "zoneId" FROM "Schedule"; +DROP TABLE "Schedule"; +ALTER TABLE "new_Schedule" RENAME TO "Schedule"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fd44c81..5475afd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -68,6 +68,8 @@ model Schedule { audio Audio @relation(fields: [audioId], references: [id]) audioId String + + audioSequence String @default("") } model DayType { diff --git a/prisma/seed.js b/prisma/seed.js index 052680e..c44dba2 100644 --- a/prisma/seed.js +++ b/prisma/seed.js @@ -45,6 +45,35 @@ const main = async () => { update: {value: sound.id} }) } + + const schedulesWithNoSequence = await prisma.schedule.findMany({ + where: {audioSequence: ''} + }) + + if (schedulesWithNoSequence.length > 0) { + console.log('Schedules need migrating to the audio sequence system.') + + const promises = schedulesWithNoSequence.map(({id, audioId, count}) => { + return new Promise(async resolve => { + const sequence = [] + + let i = 0 + while (i < count) { + sequence.push(audioId) + i++ + } + + await prisma.schedule.update({ + where: {id}, + data: {audioSequence: JSON.stringify(sequence)} + }) + + resolve() + }) + }) + + await Promise.all(promises) + } } main()